feat: Endorsement reasons

pull/21/head
db0 2023-09-13 13:58:34 +02:00
parent 861d9f72bd
commit c59b07b35a
6 changed files with 118 additions and 8 deletions

View File

@ -1,5 +1,9 @@
# Changelog # Changelog
# 0.13.0
* Can now add reasons to endorsements. Likewise now the `api/v1/approvals` endoint can filter by reasons and min endorsements.
# 0.12.0 # 0.12.0
* Added hesitations, which signify mistrust against instances. A softer form of censure, to use in silencing or closer attention instead of blocking. * Added hesitations, which signify mistrust against instances. A softer form of censure, to use in silencing or closer attention instead of blocking.

View File

@ -54,6 +54,14 @@ class Models:
'domains': fields.List(fields.String(description="The instance domains as a list.")), 'domains': fields.List(fields.String(description="The instance domains as a list.")),
'csv': fields.String(description="The instance domains as a csv."), 'csv': fields.String(description="The instance domains as a csv."),
}) })
self.response_model_instances_endorsed = api.inherit('EndorsedInstanceDetails', self.response_model_instances, {
'endorsement_reasons': fields.List(fields.String(description="The reasons instances have given for endorsing this instance")),
})
self.response_model_model_Endorsed_get = api.model('EndorsedInstances', {
'instances': fields.List(fields.Nested(self.response_model_instances_endorsed)),
'domains': fields.List(fields.String(description="The instance domains as a list.")),
'csv': fields.String(description="The instance domains as a csv."),
})
self.response_model_dubious_instances = api.inherit('DubiousInstanceDetails', self.response_model_instances, { 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_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.")), 'hesitation_evidence': fields.List(fields.String(description="Evidence justifying this hesitation, typically should be one or more URLs.")),
@ -64,6 +72,9 @@ class Models:
'domains': fields.List(fields.String(description="The instance domains as a list.")), 'domains': fields.List(fields.String(description="The instance domains as a list.")),
'csv': fields.String(description="The instance domains as a csv."), 'csv': fields.String(description="The instance domains as a csv."),
}) })
self.input_endorsements_modify = api.model('ModifyEndorsements', {
'reason': fields.String(required=False, description="The reason for this endorsement. No profanity or hate speech allowed!", example="I just think they're neat."),
})
self.input_censures_modify = api.model('ModifyCensureHesitations', { self.input_censures_modify = api.model('ModifyCensureHesitations', {
'reason': fields.String(required=False, description="The reason for this censure/hesitation. No profanity or hate speech allowed!", example="csam"), '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), '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),

View File

@ -2,16 +2,19 @@ from fediseer.apis.v1.base import *
from fediseer.classes.instance import Endorsement,Censure from fediseer.classes.instance import Endorsement,Censure
from fediseer.classes.reports import Report from fediseer.classes.reports import Report
from fediseer import enums from fediseer import enums
from fediseer.utils import sanitize_string
class Approvals(Resource): class Approvals(Resource):
get_parser = reqparse.RequestParser() get_parser = reqparse.RequestParser()
get_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers") get_parser.add_argument("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers")
get_parser.add_argument("csv", required=False, type=bool, help="Set to true to return just the domains as a csv. Mutually exclusive with domains", location="args") get_parser.add_argument("csv", required=False, type=bool, help="Set to true to return just the domains as a csv. Mutually exclusive with domains", location="args")
get_parser.add_argument("domains", required=False, type=bool, help="Set to true to return just the domains as a list. Mutually exclusive with csv", location="args") get_parser.add_argument("domains", required=False, type=bool, help="Set to true to return just the domains as a list. Mutually exclusive with csv", location="args")
get_parser.add_argument("min_endorsements", required=False, default=1, type=int, help="Limit to this amount of endorsements of more", location="args")
get_parser.add_argument("reasons_csv", required=False, type=str, help="Only retrieve endorsements where their reasons include any of the text in this csv", location="args")
@api.expect(get_parser) @api.expect(get_parser)
@cache.cached(timeout=10, query_string=True) @cache.cached(timeout=10, query_string=True)
@api.marshal_with(models.response_model_model_Whitelist_get, code=200, description='Instances', skip_none=True) @api.marshal_with(models.response_model_model_Endorsed_get, code=200, description='Instances', skip_none=True)
@api.response(404, 'Instance not registered', models.response_model_error) @api.response(404, 'Instance not registered', models.response_model_error)
def get(self, domains_csv): def get(self, domains_csv):
'''Display all endorsements given out by one or more domains '''Display all endorsements given out by one or more domains
@ -24,8 +27,29 @@ class Approvals(Resource):
if not instances: if not instances:
raise e.NotFound(f"No Instances found matching any of the provided domains. Have you remembered to register them?") raise e.NotFound(f"No Instances found matching any of the provided domains. Have you remembered to register them?")
instance_details = [] instance_details = []
for instance in database.get_all_endorsed_instances_by_approving_id([instance.id for instance in instances]): for e_instance in database.get_all_endorsed_instances_by_approving_id([instance.id for instance in instances]):
instance_details.append(instance.get_details()) endorsements = database.get_all_endorsement_reasons_for_endorsed_id(e_instance.id, [instance.id for instance in instances])
endorsement_count = len(endorsements)
endorsements = [e for e in endorsements if e.reason is not None]
e_instance_details = e_instance.get_details()
skip_instance = True
if self.args.reasons_csv:
reasons_filter = [r.strip().lower() for r in self.args.reasons_csv.split(',')]
reasons_filter = set(reasons_filter)
for r in reasons_filter:
reason_filter_counter = 0
for endorsement in endorsements:
if r in endorsement.reason.lower():
reason_filter_counter += 1
if reason_filter_counter >= self.args.min_endorsements:
skip_instance = False
break
elif endorsement_count >= self.args.min_endorsements:
skip_instance = False
if skip_instance:
continue
e_instance_details["endorsement_reasons"] = [endorsement.reason for endorsement in endorsements]
instance_details.append(e_instance_details)
if self.args.csv: if self.args.csv:
return {"csv": ",".join([instance["domain"] for instance in instance_details])},200 return {"csv": ",".join([instance["domain"] for instance in instance_details])},200
if self.args.domains: if self.args.domains:
@ -40,7 +64,7 @@ class Endorsements(Resource):
@api.expect(get_parser) @api.expect(get_parser)
@cache.cached(timeout=10, query_string=True) @cache.cached(timeout=10, query_string=True)
@api.marshal_with(models.response_model_model_Whitelist_get, code=200, description='Instances', skip_none=True) @api.marshal_with(models.response_model_model_Endorsed_get, code=200, description='Instances', skip_none=True)
@api.response(404, 'Instance not registered', models.response_model_error) @api.response(404, 'Instance not registered', models.response_model_error)
def get(self, domain): def get(self, domain):
'''Display all endorsements received by a specific domain '''Display all endorsements received by a specific domain
@ -50,8 +74,13 @@ class Endorsements(Resource):
if not instance: if not instance:
raise e.NotFound(f"No Instance found matching provided domain. Have you remembered to register it?") raise e.NotFound(f"No Instance found matching provided domain. Have you remembered to register it?")
instance_details = [] instance_details = []
for instance in database.get_all_approving_instances_by_endorsed_id(instance.id): for e_instance in database.get_all_approving_instances_by_endorsed_id(instance.id):
instance_details.append(instance.get_details()) endorsements = database.get_all_endorsement_reasons_for_endorsed_id(instance.id, [e_instance.id])
endorsements = [e for e in endorsements if e.reason is not None]
e_instance_details = e_instance.get_details()
if len(endorsements) > 0:
e_instance_details["endorsement_reasons"] = [endorsement.reason for endorsement in endorsements]
instance_details.append(e_instance_details)
if self.args.csv: if self.args.csv:
return {"csv": ",".join([instance["domain"] for instance in instance_details])},200 return {"csv": ",".join([instance["domain"] for instance in instance_details])},200
if self.args.domains: if self.args.domains:
@ -60,10 +89,11 @@ class Endorsements(Resource):
put_parser = reqparse.RequestParser() 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("apikey", type=str, required=True, help="The sending instance's API key.", location='headers')
put_parser.add_argument("reason", default=None, type=str, required=False, location="json")
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("Client-Agent", default="unknown:0:unknown", type=str, required=False, help="The client name and version.", location="headers")
@api.expect(put_parser) @api.expect(put_parser,models.input_endorsements_modify)
@api.marshal_with(models.response_model_simple_response, code=200, description='Endorse Instance') @api.marshal_with(models.response_model_simple_response, code=200, description='Endorse Instance')
@api.response(400, 'Bad Request', models.response_model_error) @api.response(400, 'Bad Request', models.response_model_error)
@api.response(401, 'Invalid API Key', models.response_model_error) @api.response(401, 'Invalid API Key', models.response_model_error)
@ -97,9 +127,13 @@ class Endorsements(Resource):
raise e.BadRequest("You can't endorse an instance you've censured! Please withdraw the censure first.") raise e.BadRequest("You can't endorse an instance you've censured! Please withdraw the censure first.")
if database.get_endorsement(target_instance.id,instance.id): if database.get_endorsement(target_instance.id,instance.id):
return {"message":'OK'}, 200 return {"message":'OK'}, 200
reason = self.args.reason
if reason is not None:
reason = sanitize_string(reason)
new_endorsement = Endorsement( new_endorsement = Endorsement(
approving_id=instance.id, approving_id=instance.id,
endorsed_id=target_instance.id, endorsed_id=target_instance.id,
reason=reason,
) )
db.session.add(new_endorsement) db.session.add(new_endorsement)
new_report = Report( new_report = Report(
@ -120,6 +154,54 @@ class Endorsements(Resource):
logger.info(f"{instance.domain} Endorsed {domain}") logger.info(f"{instance.domain} Endorsed {domain}")
return {"message":'Changed'}, 200 return {"message":'Changed'}, 200
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("reason", default=None, type=str, required=False, location="json")
@api.expect(patch_parser,models.input_endorsements_modify, validate=True)
@api.marshal_with(models.response_model_simple_response, code=200, description='Modify Endorsement')
@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 an existing endorsement
'''
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?")
target_instance = database.find_instance_by_domain(domain=domain)
if not target_instance:
raise e.BadRequest("Instance for which to modify endorsement not found")
endorsement = database.get_endorsement(target_instance.id,instance.id)
if not endorsement:
raise e.BadRequest(f"No endorsement found for {domain} from {instance.domain}")
changed = False
reason = self.args.reason
if reason is not None:
reason = sanitize_string(reason)
if endorsement.reason != reason:
endorsement.reason = reason
changed = True
if changed is False:
return {"message":'OK'}, 200
new_report = Report(
source_domain=instance.domain,
target_domain=target_instance.domain,
report_type=enums.ReportType.ENDORSEMENT,
report_activity=enums.ReportActivity.MODIFIED,
)
db.session.add(new_report)
db.session.commit()
logger.info(f"{instance.domain} Modified endorsement for {domain}")
return {"message":'Changed'}, 200
delete_parser = reqparse.RequestParser() 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("apikey", type=str, required=True, help="The sending instance's API key.", location='headers')

View File

@ -42,6 +42,7 @@ class Endorsement(db.Model):
__tablename__ = "endorsements" __tablename__ = "endorsements"
__table_args__ = (UniqueConstraint('approving_id', 'endorsed_id', name='endoresements_approving_id_endorsed_id'),) __table_args__ = (UniqueConstraint('approving_id', 'endorsed_id', name='endoresements_approving_id_endorsed_id'),)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
reason = db.Column(db.String(255), unique=False, nullable=True, index=False)
approving_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False) approving_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False)
approving_instance = db.relationship("Instance", back_populates="approvals", foreign_keys=[approving_id]) approving_instance = db.relationship("Instance", back_populates="approvals", foreign_keys=[approving_id])
endorsed_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False) endorsed_id = db.Column(db.Integer, db.ForeignKey("instances.id", ondelete="CASCADE"), nullable=False)

View File

@ -1,4 +1,4 @@
FEDISEER_VERSION = "0.11.1" FEDISEER_VERSION = "0.13.0"
SUPPORTED_SOFTWARE = [ SUPPORTED_SOFTWARE = [
"lemmy", "lemmy",
"mastodon", "mastodon",

View File

@ -65,6 +65,18 @@ def get_all_approving_instances_by_endorsed_id(endorsed_id):
) )
return query.all() return query.all()
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),
)
).with_entities(
Endorsement.reason,
)
return query.all()
def get_all_censured_instances_by_censuring_id(censuring_ids): def get_all_censured_instances_by_censuring_id(censuring_ids):
query = db.session.query( query = db.session.query(
Instance Instance