Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 142 additions & 6 deletions backend/database/models/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from typing import TYPE_CHECKING
import logging
from collections import OrderedDict
from backend.schemas import (
JsonSerializable, PropertyEnum, NodeConflictException)
from backend.database.models.contact import SocialMediaContact, EmailContact
Expand Down Expand Up @@ -353,12 +354,126 @@ def __repr__(self):
"""Represent instance as a unique string."""
return f"<Source {self.uid}>"

def get_activity_summary(self, top_locations: int = 5) -> dict:
"""Aggregate contribution history and changed-record locations."""
def month_start_offset(base: datetime, offset: int) -> datetime:
month_index = (base.year * 12 + (base.month - 1)) + offset
year = month_index // 12
month = month_index % 12 + 1
return datetime(year, month, 1)

time_query = """
WITH date.truncate('month', date()) AS current_month_start
WITH current_month_start,
current_month_start + duration('P1M') AS next_month_start,
current_month_start - duration('P11M') AS start_of_window
MATCH (s:Source {uid: $uid})<-[:ATTRIBUTED_TO]-(c:Change)
WHERE date(c.timestamp) >= start_of_window
AND date(c.timestamp) < next_month_start
WITH date.truncate('month', date(c.timestamp)) AS bucket,
count(*) AS count
RETURN toString(bucket) AS date, count
ORDER BY bucket ASC
"""
rows, _ = db.cypher_query(time_query, {"uid": self.uid})

counts_by_month = {row[0]: row[1] for row in rows}
current_month_start = datetime(
datetime.utcnow().year,
datetime.utcnow().month,
1,
)
contributions_over_time = []
for offset in range(-11, 1):
bucket = month_start_offset(current_month_start, offset)
bucket_key = bucket.date().isoformat()
contributions_over_time.append({
"date": bucket_key,
"count": counts_by_month.get(bucket_key, 0)
})

last_active_at = None
last_active_query = """
MATCH (s:Source {uid: $uid})<-[:ATTRIBUTED_TO]-(c:Change)
RETURN c.timestamp
ORDER BY c.timestamp DESC
LIMIT 1
"""
last_active_rows, _ = db.cypher_query(
last_active_query,
{"uid": self.uid},
)
if last_active_rows:
last_active = last_active_rows[0][0]
last_active_at = (
last_active.isoformat()
if hasattr(last_active, "isoformat")
else str(last_active)
)

location_query = """
MATCH (s:Source {uid: $uid})<-[:ATTRIBUTED_TO]-(c:Change)
-[:CHANGE_TO]->(n)
WHERE NOT n:Officer
OPTIONAL MATCH (n:Complaint)-[:OCCURRED_IN]->
(complaint_location:Location)
WITH
coalesce(
complaint_location.city,
CASE WHEN n:Agency THEN n.hq_city END,
CASE WHEN n:Unit THEN n.hq_city END
) AS city,
coalesce(
complaint_location.state,
CASE WHEN n:Agency THEN n.hq_state END,
CASE WHEN n:Unit THEN n.hq_state END
) AS state
WITH CASE
WHEN city IS NOT NULL AND state IS NOT NULL THEN city + ', ' + state
WHEN city IS NOT NULL THEN city
WHEN state IS NOT NULL THEN state
ELSE 'Unknown'
END AS location_label,
count(*) AS count
RETURN location_label, count
ORDER BY count DESC, location_label ASC
"""
location_rows, _ = db.cypher_query(location_query, {"uid": self.uid})

contribution_locations = []
other_count = 0
for index, row in enumerate(location_rows):
label, count = row
if index < top_locations:
contribution_locations.append({
"label": label,
"count": count
})
else:
other_count += count

if other_count:
contribution_locations.append({
"label": "Other",
"count": other_count
})

total_changes = sum(item["count"] for item in contributions_over_time)

return OrderedDict({
"last_active_at": last_active_at,
"total_changes": total_changes,
"contributions_over_time": contributions_over_time,
"contribution_locations": contribution_locations
})

def update_source(
self,
*,
name: str | None = None,
contact_email: str | None = None,
url: str | None = None,
description: str | None = None,
slug: str | None = None
) -> None:
"""Update the source details.
Expand All @@ -368,17 +483,31 @@ def update_source(
contact_email (str | None): The contact email for the source.
url (str | None): The website URL for the source.
"""
should_save = False

if name is not None and name != self.name:
self.name = name
should_save = True
if url is not None and url != self.url:
self.url = url
should_save = True
if description is not None and description != self.description:
self.description = description
should_save = True
if contact_email is not None:
email_node = EmailContact.get_or_create(contact_email)
current_email = self.primary_email.single()
self.primary_email.reconnect(current_email, email_node)
if slug is not None:
self.set_slug(slug)
self.save()
self.slug = slugify(slug)
self.slug_generated = False
self.slug_generated_from = None
should_save = True
elif should_save and self.slug_generated:
self._auto_generate_slug()
should_save = True
if should_save:
self.save()

@classmethod
def create_source(
Expand All @@ -387,6 +516,7 @@ def create_source(
name: str,
contact_email: str,
url: str | None = None,
description: str | None = None,
slug: str | None = None
) -> "Source":
"""Create a new data source.
Expand All @@ -411,13 +541,19 @@ def create_source(
except DoesNotExist:
source = cls(
name=name,
url=url
).save()
url=url,
description=description
)
if slug:
source.slug = slugify(slug)
source.slug_generated = False
source.slug_generated_from = None
else:
source._auto_generate_slug()
source = source.save()
except Exception as e:
logging.error(f"Error creating source: {e}")
raise e
if slug:
source.set_slug(slug)
email = EmailContact.get_or_create(contact_email)
social = SocialMediaContact().save()
source.primary_email.connect(email)
Expand Down
72 changes: 72 additions & 0 deletions backend/dto/user_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from pydantic import Field, field_validator

from backend.dto.common import RequestDTO
from backend.dto.common_filters import validate_state_code


class UserContactInfoDTO(RequestDTO):
additional_emails: list[str] | None = None
phone_numbers: list[str] | None = None


class UserLocationDTO(RequestDTO):
city: str | None = None
state: str | None = None

@field_validator("state")
def validate_state(cls, value):
return validate_state_code(value)


class UserEmploymentDTO(RequestDTO):
employer: str | None = None
title: str | None = None


class UserSocialMediaDTO(RequestDTO):
twitter_url: str | None = None
facebook_url: str | None = None
linkedin_url: str | None = None
instagram_url: str | None = None
youtube_url: str | None = None
tiktok_url: str | None = None


class UpdateCurrentUser(RequestDTO):
first_name: str | None = None
last_name: str | None = None
bio: str | None = None
primary_email: str | None = None
contact_info: UserContactInfoDTO | None = None
website: str | None = None
location: UserLocationDTO | None = None
employment: UserEmploymentDTO | None = None
profile_image: str | None = None
social_media: UserSocialMediaDTO | None = None


class GetUserParams(RequestDTO):
include: list[str] | None = Field(
None,
description="Related entities to include in the response.",
)

@field_validator("include")
def validate_include(cls, value):
allowed_includes = {"memberships"}
if value:
invalid = set(value) - allowed_includes
if invalid:
raise ValueError(
f"Invalid include parameters: {', '.join(sorted(invalid))}"
)
return value


class UserSuggestionParams(RequestDTO):
limit: int = Field(
4,
ge=1,
le=20,
description="Maximum number of suggested people to return.",
)
14 changes: 14 additions & 0 deletions backend/routes/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def create_source():
name=body.name,
contact_email=body.contact_email,
url=body.url,
description=body.description,
slug=body.slug)
except NodeConflictException:
abort(409, description="Source already exists")
Expand Down Expand Up @@ -170,6 +171,7 @@ def update_source(source_uid: str):
name=body.name,
contact_email=body.contact_email,
url=body.url,
description=body.description,
slug=body.slug
)
except Exception as e:
Expand Down Expand Up @@ -207,6 +209,18 @@ def get_source_members(source_uid: int):
return ordered_jsonify(results), 200


@bp.route("/<source_uid>/activity", methods=["GET"])
@jwt_required()
@min_role_required(UserRole.PUBLIC)
def get_source_activity(source_uid: str):
"""Get aggregated activity for a source."""
p = Source.nodes.get_or_none(uid=source_uid)
if p is None:
abort(404, description="Source not found")

return ordered_jsonify(p.get_activity_summary()), 200


""" This class currently doesn't work with the `source_member_to_orm`
class AddMemberSchema(BaseModel):
user_email: str
Expand Down
Loading
Loading