diff --git a/backend/database/models/agency.py b/backend/database/models/agency.py index 7af4f0168..b1b46857e 100644 --- a/backend/database/models/agency.py +++ b/backend/database/models/agency.py @@ -30,6 +30,12 @@ class UnitMembership(StructuredRel, JsonSerializable): class Unit(StructuredNode, JsonSerializable): + __property_order__ = [ + "uid", "name", "website_url", "phone", + "email", "description", "address", + "city", "state", "zip", "agency_url", + "officers_url", "date_etsablished" + ] __hidden_properties__ = ["citations"] uid = UniqueIdProperty() @@ -62,6 +68,12 @@ def __repr__(self): class Agency(StructuredNode, JsonSerializable): + __property_order__ = [ + "uid", "name", "website_url", "hq_address", + "hq_city", "hq_state", "hq_zip", + "phone", "email", "description", + "jurisdiction" + ] __hidden_properties__ = ["citations"] uid = UniqueIdProperty() diff --git a/backend/database/models/attachment.py b/backend/database/models/attachment.py index d7bbaf116..c92f559ed 100644 --- a/backend/database/models/attachment.py +++ b/backend/database/models/attachment.py @@ -2,13 +2,51 @@ from neomodel import ( StringProperty, UniqueIdProperty, - StructuredNode + StructuredNode, + DateProperty ) class Attachment(JsonSerializable, StructuredNode): + """ + Multimedia attachment. + """ + __property_order__ = [ + "uid", "title", "url", "filetype" + ] + __hidden_properties__ = ["hash"] + uid = UniqueIdProperty() title = StringProperty() hash = StringProperty() url = StringProperty() filetype = StringProperty() + + +class Article(StructuredNode, JsonSerializable): + """ + News article. + """ + + uid = UniqueIdProperty() + title = StringProperty() + publisher = StringProperty() + publication_date = DateProperty() + url = StringProperty() + + +class SocialMediaInfo(StructuredNode, JsonSerializable): + """ + Social media information. + """ + __property_order__ = [ + "twitter_url", "linkedin_url", "facebook_url", + "instagram_url", "youtube_url", "tiktok_url" + ] + + twitter_url = StringProperty() + linkedin_url = StringProperty() + facebook_url = StringProperty() + instagram_url = StringProperty() + youtube_url = StringProperty() + tiktok_url = StringProperty() diff --git a/backend/database/models/civilian.py b/backend/database/models/civilian.py index 56c7d4e00..276e6d707 100644 --- a/backend/database/models/civilian.py +++ b/backend/database/models/civilian.py @@ -1,4 +1,6 @@ """Define the Classes for Civilians.""" +from backend.schemas import JsonSerializable +from backend.database.models.types.enums import Ethnicity, Gender from neomodel import ( StructuredNode, StringProperty, @@ -7,10 +9,11 @@ ) -class Civilian(StructuredNode): +class Civilian(StructuredNode, JsonSerializable): age = IntegerProperty() - race = StringProperty() - gender = StringProperty() + age_range = StringProperty() + ethnicity = StringProperty(choices=Ethnicity.choices()) + gender = StringProperty(choices=Gender.choices()) # Relationships complaints = RelationshipTo( diff --git a/backend/database/models/complaint.py b/backend/database/models/complaint.py index 3f36b77ac..b534aca64 100644 --- a/backend/database/models/complaint.py +++ b/backend/database/models/complaint.py @@ -7,7 +7,8 @@ RelationshipTo, RelationshipFrom, DateProperty, - UniqueIdProperty + UniqueIdProperty, + ZeroOrOne ) @@ -19,36 +20,58 @@ class RecordType(str, PropertyEnum): # Neo4j Models -class BaseSourceRel(StructuredRel, JsonSerializable): +class ComplaintSourceRel(StructuredRel, JsonSerializable): + uid = UniqueIdProperty() record_type = StringProperty( choices=RecordType.choices(), required=True ) + date_published = DateProperty() - -class LegalSourceRel(BaseSourceRel): + # Legal Source Properties court = StringProperty() judge = StringProperty() docket_number = StringProperty() - date_of_action = DateProperty() - + case_event_date = DateProperty() -class NewsSourceRel(BaseSourceRel): + # News Source Properties publication_name = StringProperty() - publication_date = DateProperty() publication_url = StringProperty() author = StringProperty() author_url = StringProperty() author_email = StringProperty() - -class GovernmentSourceRel(BaseSourceRel): + # Government Source Properties reporting_agency = StringProperty() reporting_agency_url = StringProperty() reporting_agency_email = StringProperty() +class Location(StructuredNode, JsonSerializable): + __property_order__ = [ + "location_type", "location_description", + "address", "city", "state", "zip", + "responsibility", "responsibility_type" + ] + + location_type = StringProperty() + loocation_description = StringProperty() + address = StringProperty() + city = StringProperty() + state = StringProperty() + zip = StringProperty() + responsibility = StringProperty() + responsibility_type = StringProperty() + + class Complaint(StructuredNode, JsonSerializable): + __property_order__ = [ + "uid", "record_id", "category", + "incident_date", "recieved_date", + "closed_date", "reason_for_contact", + "outcome_of_contact" + ] + uid = UniqueIdProperty() record_id = StringProperty() category = StringProperty() @@ -59,22 +82,33 @@ class Complaint(StructuredNode, JsonSerializable): outcome_of_contact = StringProperty() # Relationships - source_org = RelationshipFrom("Source", "REPORTED", model=BaseSourceRel) + source_org = RelationshipFrom( + "Source", "REPORTED", model=ComplaintSourceRel) location = RelationshipTo("Location", "OCCURRED_AT") - civlian_witnesses = RelationshipFrom("Civilian", "WITNESSED") + civlian_witnesses = RelationshipFrom("models.civilian.Civilian", "WITNESSED") police_witnesses = RelationshipFrom("Officer", "WITNESSED") - attachments = RelationshipTo("Attachment", "ATTACHED_TO") + attachments = RelationshipTo( + 'backend.database.models.attachment.Attachment', "REFERENCED_IN") + articles = RelationshipTo( + 'backend.database.models.attachment.Article', "MENTIONED_IN") allegations = RelationshipTo("Allegation", "ALLEGED") investigations = RelationshipTo("Investigation", "EXAMINED_BY") penalties = RelationshipTo("Penalty", "RESULTS_IN") - civilian_review_board = RelationshipFrom("CivilianReviewBoard", "REVIEWED") + # civilian_review_board = RelationshipFrom( + # "CivilianReviewBoard", "REVIEWED") def __repr__(self): """Represent instance as a unique string.""" return f"" -class Allegation(StructuredNode): +class Allegation(StructuredNode, JsonSerializable): + __property_order__ = [ + "uid", "record_id", "allegation", + "type", "subtype", "recommended_finding", + "recommended_outcome", "finding", "outcome" + ] + uid = UniqueIdProperty() record_id = StringProperty() allegation = StringProperty() @@ -86,22 +120,29 @@ class Allegation(StructuredNode): outcome = StringProperty() # Relationships - complainant = RelationshipFrom("Civilian", "COMPLAINED_OF") + complainant = RelationshipFrom( + "models.civilian.Civilian", "COMPLAINED_OF", cardinality=ZeroOrOne) + complaint = RelationshipFrom( + "Complaint", "ALLEGED", cardinality=ZeroOrOne) accused = RelationshipFrom("Officer", "ACCUSED_OF") - complaint = RelationshipFrom("Complaint", "ALLEGED") def __repr__(self): """Represent instance as a unique string.""" return f"" -class Investigation(StructuredNode): +class Investigation(StructuredNode, JsonSerializable): + __property_order__ = [ + "uid", "start_date", "end_date" + ] + uid = UniqueIdProperty() start_date = DateProperty() end_date = DateProperty() # Relationships - investigator = RelationshipFrom("Officer", "LED_BY") + investigator = RelationshipFrom( + "Officer", "LED_BY", cardinality=ZeroOrOne) complaint = RelationshipFrom("Complaint", "EXAMINED_BY") def __repr__(self): @@ -109,13 +150,23 @@ def __repr__(self): return f"" -class Penalty(StructuredNode): +class Penalty(StructuredNode, JsonSerializable): + __property_order__ = [ + "uid", "penalty", "date_assessed", + "crb_plea", "crb_case_status", + "crb_disposition", "agency_disposition" + ] + uid = UniqueIdProperty() - description = StringProperty() + penalty = StringProperty() date_assessed = DateProperty() + crb_plea = StringProperty() + crb_case_status = StringProperty() + crb_disposition = StringProperty() + agency_disposition = StringProperty() # Relationships - officer = RelationshipFrom("Officer", "RECEIVED") + officer = RelationshipFrom("models.officer.Officer", "RECEIVED") complaint = RelationshipFrom("Complaint", "RESULTS_IN") def __repr__(self): diff --git a/backend/database/models/litigation.py b/backend/database/models/litigation.py index 7a4981bbe..2e904a78f 100644 --- a/backend/database/models/litigation.py +++ b/backend/database/models/litigation.py @@ -14,13 +14,34 @@ class LegalCaseType(str, PropertyEnum): CRIMINAL = "CRIMINAL" +class CourtLevel(str, PropertyEnum): + MUNICIPAL_OR_COUNTY = "Municipal or County" + STATE_TRIAL = "State Trial Court" + STATE_INTERMEDIATE_APPELLATE = "State Intermediate Appellate" + STATE_HIGHEST = "State Highest" + FEDERAL_DISTRICT = "Federal District" + FEDERAL_APPELLATE = "Federal Appellate" + US_SUPREME_COURT = "U.S. Supreme" + + class Litigation(StructuredNode, JsonSerializable): + """ + Represents a legal case or litigation. + """ + __property_order__ = [ + "uid", "case_type", "case_title", "state", + "court_name", "court_level", "jurisdiction", + "docket_number", "description", "start_date", + "settlement_date", "settlement_amount", + "url" + ] __hidden_properties__ = ["citations"] uid = UniqueIdProperty() case_title = StringProperty() docket_number = StringProperty() - court_level = StringProperty() + court_name = StringProperty() + court_level = StringProperty(choices=CourtLevel.choices()) jurisdiction = StringProperty() state = StringProperty() description = StringProperty() @@ -42,6 +63,10 @@ def __repr__(self): class Document(StructuredNode, JsonSerializable): + __property_order__ = [ + "uid", "title", "description", "url" + ] + uid = UniqueIdProperty() title = StringProperty() description = StringProperty() diff --git a/backend/database/models/officer.py b/backend/database/models/officer.py index 6f818b3f8..92cdf71e1 100644 --- a/backend/database/models/officer.py +++ b/backend/database/models/officer.py @@ -16,6 +16,10 @@ class StateID(StructuredNode, JsonSerializable): law enforcement agencies. For example, in New York, this would be the Tax ID Number. """ + __property_order__ = [ + "uid", "state", "id_name", "value" + ] + id_name = StringProperty() # e.g. "Tax ID Number" state = StringProperty(choices=State.choices()) # e.g. "NY" value = StringProperty() # e.g. "958938" @@ -41,6 +45,7 @@ class Officer(StructuredNode, JsonSerializable): ethnicity = StringProperty(choices=Ethnicity.choices()) gender = StringProperty(choices=Gender.choices()) date_of_birth = DateProperty() + year_of_birth = StringProperty() # Relationships state_ids = RelationshipTo('StateID', "HAS_STATE_ID") @@ -54,6 +59,10 @@ class Officer(StructuredNode, JsonSerializable): 'backend.database.models.complaint.Investigation', "LEAD_BY") commands = Relationship( 'backend.database.models.agency.Unit', "COMMANDS") + articles = RelationshipTo( + 'backend.database.models.attachment.Article', "MENTIONED_IN") + attachments = RelationshipTo( + 'backend.database.models.attachment.Attachment', "REFERENCED_IN") citations = RelationshipTo( 'backend.database.models.source.Source', "UPDATED_BY", model=Citation) diff --git a/backend/database/models/source.py b/backend/database/models/source.py index e266b50d9..75ed7c5af 100644 --- a/backend/database/models/source.py +++ b/backend/database/models/source.py @@ -7,9 +7,9 @@ RelationshipTo, RelationshipFrom, StringProperty, DateTimeProperty, UniqueIdProperty, BooleanProperty, - EmailProperty + EmailProperty, JSONProperty, + ZeroOrOne ) -from backend.database.models.complaint import BaseSourceRel class MemberRole(str, PropertyEnum): @@ -106,10 +106,13 @@ def __repr__(self): class Citation(StructuredRel, JsonSerializable): + """ + Created when a source creates or updates a record. + """ uid = UniqueIdProperty() date = DateTimeProperty(default=datetime.now()) - url = StringProperty() - diff = StringProperty() + url = StringProperty(required=True) + diff = JSONProperty() def __repr__(self): """Represent instance as a unique string.""" @@ -117,23 +120,30 @@ def __repr__(self): class Source(StructuredNode, JsonSerializable): + """ + Represents a source organization that provides data to the platform. + """ __property_order__ = [ - "uid", "name", "url", - "contact_email" + "uid", "name", "description", "website_url", + "contact_email", "contact_phone" ] + __hidden_properties__ = ["invitations", "staged_invitations"] + uid = UniqueIdProperty() name = StringProperty(unique_index=True) - url = StringProperty() + description = StringProperty() + website_url = StringProperty() contact_email = StringProperty(required=True) + contact_phone = StringProperty() # Relationships + social_media = RelationshipTo( + "backend.database.models.attachment.SocialMediaInfo", + "HAS", cardinality=ZeroOrOne) members = RelationshipFrom( "backend.database.models.user.User", "IS_MEMBER", model=SourceMember) - complaints = RelationshipTo( - "backend.database.models.complaint.Complaint", - "REPORTED", model=BaseSourceRel) invitations = RelationshipTo( "Invitation", "HAS_PENDING_INVITATION") staged_invitations = RelationshipTo( diff --git a/backend/database/models/user.py b/backend/database/models/user.py index 27fd63ed9..44da22b70 100644 --- a/backend/database/models/user.py +++ b/backend/database/models/user.py @@ -6,7 +6,8 @@ from neomodel import ( Relationship, StructuredNode, StringProperty, DateProperty, BooleanProperty, - UniqueIdProperty, EmailProperty + UniqueIdProperty, EmailProperty, ArrayProperty, + ZeroOrOne ) from backend.database.models.source import SourceMember @@ -34,7 +35,10 @@ class User(StructuredNode, JsonSerializable): __property_order__ = [ "uid", "first_name", "last_name", "email", "email_confirmed_at", - "phone_number", "role", "active" + "phone_number", "role", "active", + "additional_emails", "website", + "city", "state", "employer", + "job_title", "bio" ] uid = UniqueIdProperty() @@ -42,20 +46,32 @@ class User(StructuredNode, JsonSerializable): # User authentication information. The collation="NOCASE" is required # to search case insensitively when USER_IFIND_MODE is "nocase_collation". - email = EmailProperty(required=True, unique_index=True) + primary_email = EmailProperty(required=True, unique_index=True) email_confirmed_at = DateProperty() password_hash = StringProperty(required=True) # User information first_name = StringProperty(required=True) last_name = StringProperty(required=True) + phone_number = StringProperty() + additional_emails = ArrayProperty(StringProperty()) + website = StringProperty() + city = StringProperty() + state = StringProperty() + employer = StringProperty() + job_title = StringProperty() + bio = StringProperty() + + # Profile Image + img_ref = StringProperty() role = StringProperty( choices=UserRole.choices(), default=UserRole.PUBLIC.value) - phone_number = StringProperty() - - # Data Source Relationships + # Relationships + social_media = Relationship( + 'backend.database.models.attachment.SocialMediaInfo', + "HAS", cardinality=ZeroOrOne) sources = Relationship( 'backend.database.models.source.Source', "MEMBER_OF_SOURCE", model=SourceMember) @@ -148,6 +164,6 @@ def get_by_email(cls, email: str) -> "User": User: The User instance if found, otherwise None. """ try: - return cls.nodes.get_or_none(email=email) + return cls.nodes.get_or_none(primary_email=email) except cls.DoesNotExist: return None diff --git a/backend/routes/auth.py b/backend/routes/auth.py index e600663d7..d03c66dab 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -31,7 +31,7 @@ def login(): # Verify user if body.password is not None and body.email is not None: - user = User.nodes.first_or_none(email=body.email) + user = User.get_by_email(email=body.email) if user is not None and user.verify_password(body.password): token = create_access_token(identity=user.uid) logger.info(f"User {user.uid} logged in successfully.") @@ -72,7 +72,7 @@ def register(): logger.info(f"Registering user with email {body.email}.") # Check to see if user already exists - user = User.nodes.first_or_none(email=body.email) + user = User.get_by_email(email=body.email) if user is not None: return { "status": "Conflict", @@ -81,7 +81,7 @@ def register(): # Verify all fields included and create user if body.password is not None and body.email is not None: user = User( - email=body.email, + primary_email=body.email, password_hash=User.hash_password(body.password), first_name=body.firstname, last_name=body.lastname, @@ -94,7 +94,7 @@ def register(): code to handle adding staged_invitations-->invitations for users who have just signed up for NPDC """ - staged_invite = StagedInvitation.nodes.filter(email=user.email) + staged_invite = StagedInvitation.nodes.filter(email=user.primary_email) if staged_invite is not None and len(staged_invite) > 0: for instance in staged_invite: new_invitation = Invitation( diff --git a/backend/routes/officers.py b/backend/routes/officers.py index 9d53ddba3..8d1ba7bf9 100644 --- a/backend/routes/officers.py +++ b/backend/routes/officers.py @@ -171,7 +171,7 @@ def get_officer(officer_uid: int): @jwt_required() @min_role_required(UserRole.PUBLIC) def get_all_officers(): - """Get all officers. + """Get officers. Accepts Query Parameters for pagination: per_page: number of results per page page: page number diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 00b72ff9f..100fb07fc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -103,7 +103,7 @@ def cleanup_test_data(): @pytest.fixture def example_user(): user = User( - email=example_email, + primary_email=example_email, password_hash=User.hash_password(example_password), role=UserRole.PUBLIC.value, first_name="first", @@ -152,7 +152,7 @@ def example_officer(): @pytest.fixture # type: ignore def example_source_member(example_source): member = User( - email=member_email, + primary_email=member_email, password_hash=User.hash_password(example_password), role=UserRole.PUBLIC.value, first_name="member", @@ -184,7 +184,7 @@ def example_source_member(example_source): @pytest.fixture # type: ignore def example_contributor(): contributor = User( - email=contributor_email, + primary_email=contributor_email, password_hash=User.hash_password(example_password), role=UserRole.CONTRIBUTOR.value, first_name="contributor", @@ -236,7 +236,7 @@ def example_complaints_private_public( @pytest.fixture def admin_user(): user = User( - email=admin_email, + primary_email=admin_email, password_hash=User.hash_password(example_password), role=UserRole.ADMIN.value, first_name="admin", @@ -249,7 +249,7 @@ def admin_user(): @pytest.fixture def source_admin(example_source): user = User( - email=s_admin_email, + primary_email=s_admin_email, password_hash=User.hash_password(example_password), role=UserRole.CONTRIBUTOR.value, first_name="contributor", diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 61c264f42..643318688 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -5,7 +5,7 @@ ph = PasswordHasher() mock_user = { - "email": "existing@email.com", + "primary_email": "existing@email.com", "password_hash": ph.hash("my_password"), "first_name": "John", "last_name": "Doe", @@ -85,7 +85,7 @@ def test_login( res = client.post( "api/v1/auth/login", json={ - "email": example_user.email, + "email": example_user.primary_email, "password": password, }, ) diff --git a/backend/tests/test_sources.py b/backend/tests/test_sources.py index 52f06e8a5..4a13c998a 100644 --- a/backend/tests/test_sources.py +++ b/backend/tests/test_sources.py @@ -99,7 +99,7 @@ def example_members(example_source): for name, mock in mock_members.items(): u = User( - email=mock["user_email"], + primary_email=mock["user_email"], password_hash=User.hash_password(example_password), role=mock["user_role"], first_name=name, diff --git a/oas/common/error.yaml b/oas/common/error.yaml index fac33f59c..2d2156330 100644 --- a/oas/common/error.yaml +++ b/oas/common/error.yaml @@ -6,6 +6,9 @@ components: message: type: "string" description: "A message describing the error." + details: + type: "object" + description: "Additional details about the error." required: - message responses: