Merge pull request #54 from Fediseer/rebuttals

Feat: Rebuttals
pull/56/head
Divided by Zer0 2023-10-15 19:22:36 +02:00 committed by GitHub
commit 6d44604f99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 223 additions and 18 deletions

View File

@ -1,5 +1,10 @@
# Changelog
# 0.21.0
* Added rebuttals
* Improved speed of GET on /hesitations and /censures
# 0.20.1
* Allow filtering by software

View File

@ -62,6 +62,7 @@ class Models:
self.response_model_instances_censured = api.inherit('CensuredInstanceDetails', self.response_model_instances, {
'censure_reasons': fields.List(fields.String(description="The reasons instances have given for censuring this instance")),
'censure_evidence': fields.List(fields.String(description="Evidence justifying this censure, typically should be one or more URLs.")),
'rebuttal': fields.List(fields.String(description="Counter argument by the target instance.", example="Nuh uh!")),
'censure_count': fields.Integer(description="The amount of censures this instance has received from the reference instances"),
})
self.response_model_model_Censures_get = api.model('CensuredInstances', {
@ -80,6 +81,7 @@ class Models:
self.response_model_dubious_instances = api.inherit('DubiousInstanceDetails', self.response_model_instances, {
'hesitation_reasons': fields.List(fields.String(description="The reasons instances have given for hesitating against this instance")),
'hesitation_evidence': fields.List(fields.String(description="Evidence justifying this hesitation, typically should be one or more URLs.")),
'rebuttal': fields.List(fields.String(description="Counter argument by the target instance.", example="Nuh uh!")),
'hesitation_count': fields.Integer(description="The amount of hesitations this instance has received from the reference instances"),
})
self.response_model_model_Hesitations_get = api.model('DubiousInstances', {
@ -94,6 +96,9 @@ class Models:
'reason': fields.String(required=False, description="The reason for this censure/hesitation. No profanity or hate speech allowed!", example="csam"),
'evidence': fields.String(required=False, description="The evidence for this censure/hesitation. Typically URL but can be a long form of anything you feel appropriate.", example="https://link.to/your/evidence", max_length=1000),
})
self.input_rebuttals_modify = api.model('ModifyRebuttals', {
'rebuttal': fields.String(required=False, description="The counter-argument for this censure/hesitation.", example="Nuh uh!", max_length=1000),
})
self.response_model_api_key_reset = api.model('ApiKeyReset', {
"message": fields.String(default='OK',required=True, description="The result of this operation."),
"new_key": fields.String(default=None,required=False, description="The new API key"),

View File

@ -4,6 +4,7 @@ import fediseer.apis.v1.solicitations as solicitations
import fediseer.apis.v1.endorsements as endorsements
import fediseer.apis.v1.censures as censures
import fediseer.apis.v1.hesitations as hesitations
import fediseer.apis.v1.rebuttals as rebuttals
import fediseer.apis.v1.guarantees as guarantees
import fediseer.apis.v1.activitypub as activitypub
import fediseer.apis.v1.badges as badges
@ -27,6 +28,7 @@ api.add_resource(endorsements.BatchEndorsements, "/batch/endorsements")
api.add_resource(censures.Censures, "/censures/<string:domain>")
api.add_resource(censures.CensuresGiven, "/censures_given/<string:domains_csv>")
api.add_resource(censures.BatchCensures, "/batch/censures")
api.add_resource(rebuttals.Rebuttals, "/rebuttals/<string:domain>")
api.add_resource(hesitations.Hesitations, "/hesitations/<string:domain>")
api.add_resource(hesitations.HesitationsGiven, "/hesitations_given/<string:domains_csv>")
api.add_resource(hesitations.BatchHesitations, "/batch/hesitations")

View File

@ -154,13 +154,15 @@ class Censures(Resource):
if p_instance != get_instance:
continue
instances.append(p_instance)
censures = database.get_all_censure_reasons_for_censured_id(instance.id, [c.id for c in instances])
rebuttals = database.get_all_rebuttals_from_source_instance_id(instance.id,[c.id for c in instances])
for c_instance in instances:
censures = database.get_all_censure_reasons_for_censured_id(instance.id, [c_instance.id])
censures = [c for c in censures if c.reason is not None]
censures = [c for c in censures if c.reason is not None and c.censuring_id == c_instance.id]
c_instance_details = c_instance.get_details()
if len(censures) > 0:
c_instance_details["censure_reasons"] = [censure.reason for censure in censures]
c_instance_details["censure_evidence"] = [censure.evidence for censure in censures if censure.evidence is not None]
c_instance_details["rebuttal"] = [r.rebuttal for r in rebuttals if r.target_id == c_instance.id]
instance_details.append(c_instance_details)
if self.args.csv:
return {"csv": ",".join([instance["domain"] for instance in instance_details])},200

View File

@ -140,13 +140,15 @@ class Hesitations(Resource):
continue
instances.append(p_instance)
instance_details = []
hesitations = database.get_all_hesitation_reasons_for_dubious_id(instance.id, [c.id for c in instances])
rebuttals = database.get_all_rebuttals_from_source_instance_id(instance.id,[c.id for c in instances])
for c_instance in instances:
hesitations = database.get_all_hesitation_reasons_for_dubious_id(instance.id, [c_instance.id])
hesitations = [c for c in hesitations if c.reason is not None]
hesitations = [c for c in hesitations if c.reason is not None and c.hesitant_id == c_instance.id]
c_instance_details = c_instance.get_details()
if len(hesitations) > 0:
c_instance_details["hesitation_reasons"] = [hesitation.reason for hesitation in hesitations]
c_instance_details["hesitation_evidence"] = [hesitation.evidence for hesitation in hesitations if hesitation.evidence is not None]
c_instance_details["rebuttal"] = [r.rebuttal for r in rebuttals if r.target_id == c_instance.id]
instance_details.append(c_instance_details)
if self.args.csv:
return {"csv": ",".join([instance["domain"] for instance in instance_details])},200

View File

@ -0,0 +1,162 @@
from fediseer.apis.v1.base import *
from fediseer.classes.instance import Rebuttal
from fediseer.utils import sanitize_string
from fediseer.classes.reports import Report
from fediseer import enums
class Rebuttals(Resource):
decorators = [limiter.limit("45/minute"), limiter.limit("30/minute", key_func = get_request_path)]
put_parser = reqparse.RequestParser()
put_parser.add_argument("apikey", type=str, required=True, help="The sending instance's API key.", location='headers')
put_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers")
put_parser.add_argument("rebuttal", default=None, type=str, required=True, location="json")
@api.expect(put_parser,models.input_rebuttals_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Rebut Censure or Hesitation against your instance')
@api.response(400, 'Bad Request', models.response_model_error)
@api.response(401, 'Invalid API Key', models.response_model_error)
@api.response(403, 'Access Denied', models.response_model_error)
@api.response(404, 'Instance not registered', models.response_model_error)
def put(self, domain):
'''Rebut a Censure or Hesitation against your instance
Use this to provide evidence against, or to initiate discussion
'''
self.args = self.put_parser.parse_args()
if not self.args.apikey:
raise e.Unauthorized("You must provide the API key that was PM'd to your admin account")
instance = database.find_instance_by_api_key(self.args.apikey)
if not instance:
raise e.NotFound(f"No Instance found matching provided API key and domain. Have you remembered to register it?")
if len(instance.guarantors) == 0:
raise e.Forbidden("Only guaranteed instances can rebut.")
if database.instance_has_flag(instance.id,enums.InstanceFlags.RESTRICTED):
raise e.Forbidden("You cannot take this action as your instance is restricted")
if database.has_too_many_actions_per_min(instance.domain):
raise e.TooManyRequests("Your instance is doing more than 20 actions per minute. Please slow down.")
unbroken_chain, chainbreaker = database.has_unbroken_chain(instance.id)
if not unbroken_chain:
raise e.Forbidden(f"Guarantee chain for this instance has been broken. Chain ends at {chainbreaker.domain}!")
target_instance = database.find_instance_by_domain(domain)
if not target_instance:
raise e.NotFound(f"Instance {domain} is not registered on the fediseer.")
if database.get_rebuttal(target_instance.id,instance.id):
return {"message":'OK'}, 200
censure = database.get_censure(instance.id, target_instance.id)
if not censure or target_instance.visibility_censures != enums.ListVisibility.OPEN:
hesitation = database.get_hesitation(instance.id, target_instance.id)
if not hesitation or target_instance.visibility_hesitations != enums.ListVisibility.OPEN:
raise e.BadRequest(f"Either no censure or hesitation from {domain} found towards {instance.domain}, or they are not openly visible.")
rebuttal_value = self.args.rebuttal
if rebuttal_value is not None:
rebuttal_value = sanitize_string(rebuttal_value)
new_rebuttal = Rebuttal(
source_id=instance.id,
target_id=target_instance.id,
rebuttal=rebuttal_value,
)
db.session.add(new_rebuttal)
target_domain = target_instance.domain
new_report = Report(
source_domain=instance.domain,
target_domain=target_domain,
report_type=enums.ReportType.REBUTTAL,
report_activity=enums.ReportActivity.ADDED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} Rebutted {domain}")
return {"message":'Changed'}, 200
decorators = [limiter.limit("20/minute", key_func = get_request_path)]
patch_parser = reqparse.RequestParser()
patch_parser.add_argument("apikey", type=str, required=True, help="The sending instance's API key.", location='headers')
patch_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers")
patch_parser.add_argument("rebuttal", default=None, type=str, required=False, location="json")
@api.expect(patch_parser,models.input_rebuttals_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Modify Rebuttal')
@api.response(400, 'Bad Request', models.response_model_error)
@api.response(401, 'Invalid API Key', models.response_model_error)
@api.response(403, 'Not Guaranteed', models.response_model_error)
@api.response(404, 'Instance not registered', models.response_model_error)
def patch(self, domain):
'''Modify a Rebuttal
'''
self.args = self.patch_parser.parse_args()
if not self.args.apikey:
raise e.Unauthorized("You must provide the API key that was PM'd to your admin account")
instance = database.find_instance_by_api_key(self.args.apikey)
if not instance:
raise e.NotFound(f"No Instance found matching provided API key and domain. Have you remembered to register it?")
if database.has_too_many_actions_per_min(instance.domain):
raise e.TooManyRequests("Your instance is doing more than 20 actions per minute. Please slow down.")
target_instance = database.find_instance_by_domain(domain=domain)
if not target_instance:
raise e.BadRequest("Instance from which to modify censure not found")
rebuttal = database.get_rebuttal(target_instance.id,instance.id)
if not rebuttal:
raise e.BadRequest(f"No Rebuttal found for {domain} from {instance.domain}")
changed = False
rebuttal_value = self.args.rebuttal
if rebuttal_value is not None:
rebuttal_value = sanitize_string(rebuttal_value)
if rebuttal.rebuttal != rebuttal_value:
rebuttal.rebuttal = rebuttal_value
changed = True
if changed is False:
return {"message":'OK'}, 200
target_domain = target_instance.domain
if instance.visibility_censures != enums.ListVisibility.OPEN:
target_domain = '[REDACTED]'
new_report = Report(
source_domain=instance.domain,
target_domain=target_domain,
report_type=enums.ReportType.REBUTTAL,
report_activity=enums.ReportActivity.MODIFIED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} modified rebuttal about {domain}")
return {"message":'Changed'}, 200
decorators = [limiter.limit("20/minute", key_func = get_request_path)]
delete_parser = reqparse.RequestParser()
delete_parser.add_argument("apikey", type=str, required=True, help="The sending instance's API key.", location='headers')
delete_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers")
@api.expect(delete_parser)
@api.marshal_with(models.response_model_simple_response, code=200, description='Delete Rebuttal')
@api.response(400, 'Bad Request', models.response_model_error)
@api.response(401, 'Invalid API Key', models.response_model_error)
@api.response(404, 'Instance not registered', models.response_model_error)
def delete(self,domain):
'''Delete a rebuttal
'''
self.args = self.delete_parser.parse_args()
if not self.args.apikey:
raise e.Unauthorized("You must provide the API key that was PM'd to your admin account")
instance = database.find_instance_by_api_key(self.args.apikey)
if not instance:
raise e.NotFound(f"No Instance found matching provided API key and domain. Have you remembered to register it?")
target_instance = database.find_instance_by_domain(domain=domain)
if not target_instance:
raise e.BadRequest("Instance from which to withdraw censure not found")
rebuttal = database.get_rebuttal(target_instance.id,instance.id)
if not rebuttal:
return {"message":'OK'}, 200
db.session.delete(rebuttal)
target_domain = target_instance.domain
new_report = Report(
source_domain=instance.domain,
target_domain=target_domain,
report_type=enums.ReportType.REBUTTAL,
report_activity=enums.ReportActivity.DELETED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} delete rebuttal about {domain}")
return {"message":'Changed'}, 200

View File

@ -72,6 +72,17 @@ class Hesitation(db.Model):
dubious_instance = db.relationship("Instance", back_populates="hesitations_received", foreign_keys=[dubious_id])
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class Rebuttal(db.Model):
__tablename__ = "rebuttals"
__table_args__ = (UniqueConstraint('source_id', 'target_id', name='rebuttal_source_id_target_id'),)
id = db.Column(db.Integer, primary_key=True)
rebuttal = db.Column(db.Text, unique=False, nullable=False, index=False)
source_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False, index=True)
source_instance = db.relationship("Instance", back_populates="rebuttals_given", foreign_keys=[source_id])
target_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False, index=True)
target_instance = db.relationship("Instance", back_populates="rebuttals_received", foreign_keys=[target_id])
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
class Solicitation(db.Model):
__tablename__ = "solicitations"
__table_args__ = (UniqueConstraint('source_id', 'target_id', name='solicitations_source_id_target_id'),)
@ -134,6 +145,8 @@ class Instance(db.Model):
censures_received = db.relationship("Censure", back_populates="censured_instance", cascade="all, delete-orphan", foreign_keys=[Censure.censured_id])
hesitations_given = db.relationship("Hesitation", back_populates="hesitating_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.hesitant_id])
hesitations_received = db.relationship("Hesitation", back_populates="dubious_instance", cascade="all, delete-orphan", foreign_keys=[Hesitation.dubious_id])
rebuttals_given = db.relationship("Rebuttal", back_populates="source_instance", cascade="all, delete-orphan", foreign_keys=[Rebuttal.source_id])
rebuttals_received = db.relationship("Rebuttal", back_populates="target_instance", cascade="all, delete-orphan", foreign_keys=[Rebuttal.target_id])
solicitations_requested = db.relationship("Solicitation", back_populates="source_instance", cascade="all, delete-orphan", foreign_keys=[Solicitation.source_id])
solicitations_received = db.relationship("Solicitation", back_populates="target_instance", cascade="all, delete-orphan", foreign_keys=[Solicitation.target_id])
guarantees = db.relationship("Guarantee", back_populates="guarantor_instance", cascade="all, delete-orphan", foreign_keys=[Guarantee.guarantor_id])

View File

@ -1,4 +1,4 @@
FEDISEER_VERSION = "0.20.0"
FEDISEER_VERSION = "0.22.0"
SUPPORTED_SOFTWARE = {
"lemmy",
"mastodon",

View File

@ -4,7 +4,7 @@ from sqlalchemy import func, or_, and_, not_, Boolean
from fediseer.flask import db
from fediseer.utils import hash_api_key
from sqlalchemy.orm import joinedload
from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation, Solicitation, InstanceFlag, InstanceTag
from fediseer.classes.instance import Instance, Endorsement, Guarantee, RejectionRecord, Censure, Hesitation, Solicitation, InstanceFlag, InstanceTag, Rebuttal
from fediseer.classes.user import Claim, User
from fediseer.classes.reports import Report
from fediseer import enums
@ -93,10 +93,8 @@ def get_all_approving_instances_by_endorsed_id(endorsed_id):
def get_all_endorsement_reasons_for_endorsed_id(endorsed_id, approving_ids):
query = Endorsement.query.filter(
and_(
Endorsement.endorsed_id == endorsed_id,
Endorsement.approving_id.in_(approving_ids),
)
Endorsement.endorsed_id == endorsed_id,
Endorsement.approving_id.in_(approving_ids),
).with_entities(
Endorsement.reason,
)
@ -146,11 +144,10 @@ def get_all_censuring_instances_by_censured_id(censured_id):
def get_all_censure_reasons_for_censured_id(censured_id, censuring_ids):
query = Censure.query.filter(
and_(
Censure.censured_id == censured_id,
Censure.censuring_id.in_(censuring_ids),
)
Censure.censured_id == censured_id,
Censure.censuring_id.in_(censuring_ids),
).with_entities(
Censure.censuring_id,
Censure.reason,
Censure.evidence,
)
@ -200,17 +197,32 @@ def get_all_hesitant_instances_by_dubious_id(dubious_id):
def get_all_hesitation_reasons_for_dubious_id(dubious_id, hesitant_ids):
query = Hesitation.query.filter(
and_(
Hesitation.dubious_id == dubious_id,
Hesitation.hesitant_id.in_(hesitant_ids),
)
Hesitation.dubious_id == dubious_id,
Hesitation.hesitant_id.in_(hesitant_ids),
).with_entities(
Hesitation.hesitant_id,
Hesitation.reason,
Hesitation.evidence,
)
return query.all()
def get_rebuttal(target_instance_id, source_instance_id):
query = Rebuttal.query.filter_by(
target_id=target_instance_id,
source_id=source_instance_id,
)
return query.first()
def get_all_rebuttals_from_source_instance_id(source_instance_id, target_ids = None):
query = Rebuttal.query.filter(
Rebuttal.source_id == source_instance_id,
).with_entities(
Rebuttal.rebuttal,
Rebuttal.target_id,
)
if target_ids is not None:
query = query.filter(Rebuttal.target_id.in_(target_ids))
return query.all()
def get_all_guaranteed_instances_by_guarantor_id(guarantor_id):
query = db.session.query(

View File

@ -8,6 +8,7 @@ class ReportType(enum.Enum):
CLAIM = 4
SOLICITATION = 5
FLAG = 6
REBUTTAL = 7
class ReportActivity(enum.Enum):
ADDED = 0

View File

@ -0,0 +1 @@
ALTER TYPE reporttype ADD VALUE 'REBUTTAL';