diff --git a/alembic/versions/2025_12_01_1632-1d3398f9cd8a_create_anonymous_session_users.py b/alembic/versions/2025_12_01_1632-1d3398f9cd8a_create_anonymous_session_users.py index af4553d2..e3dafbbc 100644 --- a/alembic/versions/2025_12_01_1632-1d3398f9cd8a_create_anonymous_session_users.py +++ b/alembic/versions/2025_12_01_1632-1d3398f9cd8a_create_anonymous_session_users.py @@ -11,15 +11,142 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID +from src.util.alembic_helpers import created_at_column + # revision identifiers, used by Alembic. revision: str = '1d3398f9cd8a' down_revision: Union[str, None] = '5d6412540aba' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +def _alter_anonymous_annotation_agency(): + # Add new column + op.add_column( + "anonymous_annotation_agency", + sa.Column( + "session_id", + UUID, + sa.ForeignKey("anonymous_sessions.id"), + nullable=False + ) + ) + + # Drop prior unique constraint/primary key + op.drop_constraint( + "anonymous_annotation_agency_pkey", + "anonymous_annotation_agency" + ) + + # Add new unique constraint/primary key + op.create_primary_key( + "anonymous_annotation_agency_pkey", + "anonymous_annotation_agency", + ["session_id", "url_id", "agency_id"] + ) + +def _alter_anonymous_annotation_location(): + # Add new column + op.add_column( + "anonymous_annotation_location", + sa.Column( + "session_id", + UUID, + sa.ForeignKey("anonymous_sessions.id"), + nullable=False + ) + ) + + # Drop prior unique constraint/primary key + op.drop_constraint( + "anonymous_annotation_location_pkey", + "anonymous_annotation_location" + ) + + # Add new unique constraint/primary key + op.create_primary_key( + "anonymous_annotation_location_pkey", + "anonymous_annotation_location", + ["session_id", "url_id", "location_id"] + ) + +def _alter_anonymous_annotation_record_type(): + # Add new column + op.add_column( + "anonymous_annotation_record_type", + sa.Column( + "session_id", + UUID, + sa.ForeignKey("anonymous_sessions.id"), + nullable=False + ) + ) + + # Drop prior unique constraint/primary key + op.drop_constraint( + "anonymous_annotation_record_type_pkey", + "anonymous_annotation_record_type" + ) + + # Add new unique constraint/primary key + op.create_primary_key( + "anonymous_annotation_record_type_pkey", + "anonymous_annotation_record_type", + ["session_id", "url_id", "record_type"] + ) + +def _alter_anonymous_annotation_url_type(): + # Add new column + op.add_column( + "anonymous_annotation_url_type", + sa.Column( + "session_id", + UUID, + sa.ForeignKey("anonymous_sessions.id"), + nullable=False + ) + ) + + # Drop prior unique constraint/primary key + op.drop_constraint( + "anonymous_annotation_url_type_pkey", + "anonymous_annotation_url_type" + ) + + # Add new unique constraint/primary key + op.create_primary_key( + "anonymous_annotation_url_type_pkey", + "anonymous_annotation_url_type", + ["session_id", "url_id", "url_type"] + ) def upgrade() -> None: # Create anonymous_sessions table + _create_anonymous_sessions_table() + + # Remove all prior anonymous annotations + _remove_prior_sessions() + + _alter_anonymous_annotation_agency() + _alter_anonymous_annotation_location() + _alter_anonymous_annotation_record_type() + _alter_anonymous_annotation_url_type() + + +def _remove_prior_sessions(): + for table in [ + "anonymous_annotation_agency", + "anonymous_annotation_location", + "anonymous_annotation_record_type", + "anonymous_annotation_url_type" + ]: + op.execute( + f""" + DELETE FROM {table} + """ + ) + + +def _create_anonymous_sessions_table(): op.create_table( "anonymous_sessions", sa.Column( @@ -28,12 +155,9 @@ def upgrade() -> None: server_default=sa.text("gen_random_uuid()"), primary_key=True ), + created_at_column() ) - # TODO: Update anonymous tables to link to anonymous sessions table - - ## TODO: Drop any unique IDs forbidding more than a single ID for these columns - def downgrade() -> None: pass diff --git a/src/api/endpoints/annotate/_shared/extract.py b/src/api/endpoints/annotate/_shared/extract.py index 1a0932d3..c0459e04 100644 --- a/src/api/endpoints/annotate/_shared/extract.py +++ b/src/api/endpoints/annotate/_shared/extract.py @@ -24,7 +24,7 @@ async def extract_and_format_get_annotation_result( session: AsyncSession, url: URL, batch_id: int | None = None -): +) -> GetNextURLForAllAnnotationResponse: html_response_info = DTOConverter.html_content_list_to_html_response_info( url.html_content ) diff --git a/src/api/endpoints/annotate/anonymous/get/helpers.py b/src/api/endpoints/annotate/anonymous/get/helpers.py new file mode 100644 index 00000000..83a10845 --- /dev/null +++ b/src/api/endpoints/annotate/anonymous/get/helpers.py @@ -0,0 +1,27 @@ +from typing import Protocol, TypeVar +from uuid import UUID + +from marshmallow.fields import Bool +from sqlalchemy import Exists, select, exists, ColumnElement, Boolean + +from src.db.models.impl.url.core.sqlalchemy import URL +from src.db.models.mixins import AnonymousSessionMixin, URLDependentMixin +from src.db.models.templates_.base import Base + + +class AnonymousURLModelProtocol( + Protocol, +): + session_id: ColumnElement[UUID] + url_id: ColumnElement[int] + +AnonModel = TypeVar("AnonModel", bound=AnonymousURLModelProtocol) + +def not_exists_anon_annotation(session_id: UUID, anon_model: AnonModel) -> ColumnElement[bool]: + return ~exists( + select(anon_model.url_id) + .where( + anon_model.url_id == URL.id, + anon_model.session_id == session_id, + ) + ) \ No newline at end of file diff --git a/src/api/endpoints/annotate/anonymous/get/query.py b/src/api/endpoints/annotate/anonymous/get/query.py index 7e5f2e53..041d5cda 100644 --- a/src/api/endpoints/annotate/anonymous/get/query.py +++ b/src/api/endpoints/annotate/anonymous/get/query.py @@ -1,14 +1,21 @@ from typing import Any +from uuid import UUID -from sqlalchemy import Select, func +from sqlalchemy import Select, func, exists, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from src.api.endpoints.annotate._shared.extract import extract_and_format_get_annotation_result from src.api.endpoints.annotate.all.get.models.response import GetNextURLForAllAnnotationResponse +from src.api.endpoints.annotate.anonymous.get.helpers import not_exists_anon_annotation +from src.api.endpoints.annotate.anonymous.get.response import GetNextURLForAnonymousAnnotationResponse from src.collectors.enums import URLStatus from src.db.helpers.query import not_exists_url +from src.db.models.impl.flag.url_suspended.sqlalchemy import FlagURLSuspended from src.db.models.impl.url.core.sqlalchemy import URL +from src.db.models.impl.url.suggestion.anonymous.agency.sqlalchemy import AnonymousAnnotationAgency +from src.db.models.impl.url.suggestion.anonymous.location.sqlalchemy import AnonymousAnnotationLocation +from src.db.models.impl.url.suggestion.anonymous.record_type.sqlalchemy import AnonymousAnnotationRecordType from src.db.models.impl.url.suggestion.anonymous.url_type.sqlalchemy import AnonymousAnnotationURLType from src.db.models.views.unvalidated_url import UnvalidatedURL from src.db.models.views.url_anno_count import URLAnnotationCount @@ -18,7 +25,14 @@ class GetNextURLForAnonymousAnnotationQueryBuilder(QueryBuilderBase): - async def run(self, session: AsyncSession) -> GetNextURLForAllAnnotationResponse: + def __init__( + self, + session_id: UUID + ): + super().__init__() + self.session_id = session_id + + async def run(self, session: AsyncSession) -> GetNextURLForAnonymousAnnotationResponse: query = ( Select(URL) @@ -37,7 +51,31 @@ async def run(self, session: AsyncSession) -> GetNextURLForAllAnnotationResponse ) .where( URL.status == URLStatus.OK.value, - not_exists_url(AnonymousAnnotationURLType) + # Must not have been previously annotated by user + not_exists_anon_annotation( + session_id=self.session_id, + anon_model=AnonymousAnnotationURLType + ), + not_exists_anon_annotation( + session_id=self.session_id, + anon_model=AnonymousAnnotationRecordType + ), + not_exists_anon_annotation( + session_id=self.session_id, + anon_model=AnonymousAnnotationLocation + ), + not_exists_anon_annotation( + session_id=self.session_id, + anon_model=AnonymousAnnotationAgency + ), + ~exists( + select( + FlagURLSuspended.url_id + ) + .where( + FlagURLSuspended.url_id == URL.id, + ) + ) ) .options( joinedload(URL.html_content), @@ -46,7 +84,8 @@ async def run(self, session: AsyncSession) -> GetNextURLForAllAnnotationResponse joinedload(URL.name_suggestions), ) .order_by( - func.random() + URLAnnotationCount.total_anno_count.desc(), + URL.id.asc() ) .limit(1) ) @@ -54,8 +93,13 @@ async def run(self, session: AsyncSession) -> GetNextURLForAllAnnotationResponse raw_results = (await session.execute(query)).unique() url: URL | None = raw_results.scalars().one_or_none() if url is None: - return GetNextURLForAllAnnotationResponse( - next_annotation=None + return GetNextURLForAnonymousAnnotationResponse( + next_annotation=None, + session_id=self.session_id ) - return await extract_and_format_get_annotation_result(session, url=url) + response: GetNextURLForAllAnnotationResponse = await extract_and_format_get_annotation_result(session, url=url) + return GetNextURLForAnonymousAnnotationResponse( + session_id=self.session_id, + next_annotation=response.next_annotation + ) diff --git a/src/api/endpoints/annotate/anonymous/get/response.py b/src/api/endpoints/annotate/anonymous/get/response.py new file mode 100644 index 00000000..e54403bc --- /dev/null +++ b/src/api/endpoints/annotate/anonymous/get/response.py @@ -0,0 +1,10 @@ +from uuid import UUID + +from pydantic import BaseModel + +from src.api.endpoints.annotate.all.get.models.response import GetNextURLForAllAnnotationInnerResponse + + +class GetNextURLForAnonymousAnnotationResponse(BaseModel): + next_annotation: GetNextURLForAllAnnotationInnerResponse | None + session_id: UUID \ No newline at end of file diff --git a/src/api/endpoints/annotate/anonymous/post/query.py b/src/api/endpoints/annotate/anonymous/post/query.py index faa7aa1d..593d79d9 100644 --- a/src/api/endpoints/annotate/anonymous/post/query.py +++ b/src/api/endpoints/annotate/anonymous/post/query.py @@ -1,3 +1,5 @@ +from uuid import UUID + from sqlalchemy.ext.asyncio import AsyncSession from src.api.endpoints.annotate.all.post.models.request import AllAnnotationPostInfo @@ -11,10 +13,12 @@ class AddAnonymousAnnotationsToURLQueryBuilder(QueryBuilderBase): def __init__( self, + session_id: UUID, url_id: int, post_info: AllAnnotationPostInfo ): super().__init__() + self.session_id = session_id self.url_id = url_id self.post_info = post_info @@ -22,14 +26,16 @@ async def run(self, session: AsyncSession) -> None: url_type_suggestion = AnonymousAnnotationURLType( url_id=self.url_id, - url_type=self.post_info.suggested_status + url_type=self.post_info.suggested_status, + session_id=self.session_id ) session.add(url_type_suggestion) if self.post_info.record_type is not None: record_type_suggestion = AnonymousAnnotationRecordType( url_id=self.url_id, - record_type=self.post_info.record_type + record_type=self.post_info.record_type, + session_id=self.session_id ) session.add(record_type_suggestion) @@ -37,7 +43,8 @@ async def run(self, session: AsyncSession) -> None: location_suggestions = [ AnonymousAnnotationLocation( url_id=self.url_id, - location_id=location_id + location_id=location_id, + session_id=self.session_id ) for location_id in self.post_info.location_info.location_ids ] @@ -47,7 +54,8 @@ async def run(self, session: AsyncSession) -> None: agency_suggestions = [ AnonymousAnnotationAgency( url_id=self.url_id, - agency_id=agency_id + agency_id=agency_id, + session_id=self.session_id ) for agency_id in self.post_info.agency_info.agency_ids ] diff --git a/src/api/endpoints/annotate/routes.py b/src/api/endpoints/annotate/routes.py index a09ee1ec..1633eb5a 100644 --- a/src/api/endpoints/annotate/routes.py +++ b/src/api/endpoints/annotate/routes.py @@ -1,3 +1,6 @@ +import uuid +from uuid import UUID + from fastapi import APIRouter, Depends, Query from src.api.dependencies import get_async_core @@ -7,8 +10,10 @@ from src.api.endpoints.annotate.all.post.models.request import AllAnnotationPostInfo from src.api.endpoints.annotate.all.post.query import AddAllAnnotationsToURLQueryBuilder from src.api.endpoints.annotate.anonymous.get.query import GetNextURLForAnonymousAnnotationQueryBuilder +from src.api.endpoints.annotate.anonymous.get.response import GetNextURLForAnonymousAnnotationResponse from src.api.endpoints.annotate.anonymous.post.query import AddAnonymousAnnotationsToURLQueryBuilder from src.core.core import AsyncCore +from src.db.queries.implementations.anonymous_session import MakeAnonymousSessionQueryBuilder from src.security.dtos.access_info import AccessInfo from src.security.manager import get_access_info @@ -33,26 +38,38 @@ @annotate_router.get("/anonymous") async def get_next_url_for_all_annotations_anonymous( async_core: AsyncCore = Depends(get_async_core), -) -> GetNextURLForAllAnnotationResponse: + session_id: UUID | None = Query(description="The session id of the anonymous user.", default=None) +) -> GetNextURLForAnonymousAnnotationResponse: + # If session_id is not provided, generate new UUID + if session_id is None: + session_id: uuid.UUID = await async_core.adb_client.run_query_builder( + MakeAnonymousSessionQueryBuilder() + ) + return await async_core.adb_client.run_query_builder( - GetNextURLForAnonymousAnnotationQueryBuilder() + GetNextURLForAnonymousAnnotationQueryBuilder(session_id=session_id) ) + @annotate_router.post("/anonymous/{url_id}") async def annotate_url_for_all_annotations_and_get_next_url_anonymous( url_id: int, all_annotation_post_info: AllAnnotationPostInfo, async_core: AsyncCore = Depends(get_async_core), -) -> GetNextURLForAllAnnotationResponse: + session_id: UUID = Query(description="The session id of the anonymous user") +) -> GetNextURLForAnonymousAnnotationResponse: await async_core.adb_client.run_query_builder( AddAnonymousAnnotationsToURLQueryBuilder( url_id=url_id, - post_info=all_annotation_post_info + post_info=all_annotation_post_info, + session_id=session_id ) ) return await async_core.adb_client.run_query_builder( - GetNextURLForAnonymousAnnotationQueryBuilder() + GetNextURLForAnonymousAnnotationQueryBuilder( + session_id=session_id + ) ) diff --git a/src/api/endpoints/submit/data_source/queries/core.py b/src/api/endpoints/submit/data_source/queries/core.py index b3d1ff46..1f97cd11 100644 --- a/src/api/endpoints/submit/data_source/queries/core.py +++ b/src/api/endpoints/submit/data_source/queries/core.py @@ -1,3 +1,4 @@ +import uuid from typing import Any from sqlalchemy.exc import IntegrityError @@ -8,6 +9,7 @@ from src.collectors.enums import URLStatus from src.core.enums import BatchStatus from src.db.models.impl.batch.sqlalchemy import Batch +from src.db.models.impl.flag.url_validated.enums import URLType from src.db.models.impl.link.batch_url.sqlalchemy import LinkBatchURL from src.db.models.impl.url.core.enums import URLSource from src.db.models.impl.url.core.sqlalchemy import URL @@ -15,9 +17,11 @@ from src.db.models.impl.url.suggestion.anonymous.agency.sqlalchemy import AnonymousAnnotationAgency from src.db.models.impl.url.suggestion.anonymous.location.sqlalchemy import AnonymousAnnotationLocation from src.db.models.impl.url.suggestion.anonymous.record_type.sqlalchemy import AnonymousAnnotationRecordType +from src.db.models.impl.url.suggestion.anonymous.url_type.sqlalchemy import AnonymousAnnotationURLType from src.db.models.impl.url.suggestion.name.enums import NameSuggestionSource from src.db.models.impl.url.suggestion.name.sqlalchemy import URLNameSuggestion from src.db.queries.base.builder import QueryBuilderBase +from src.db.queries.implementations.anonymous_session import MakeAnonymousSessionQueryBuilder from src.util.models.full_url import FullURL @@ -67,11 +71,23 @@ async def run( ) session.add(batch_url_link) + # Create single-use session id + session_id: uuid.UUID = await MakeAnonymousSessionQueryBuilder().run(session=session) + + # Add URL Type Suggestion + url_type_suggestion = AnonymousAnnotationURLType( + url_id=url_id, + url_type=URLType.DATA_SOURCE, + session_id=session_id + ) + session.add(url_type_suggestion) + # Optionally add Record Type as suggestion if self.request.record_type is not None: record_type_suggestion = AnonymousAnnotationRecordType( url_id=url_id, - record_type=self.request.record_type.value + record_type=self.request.record_type.value, + session_id=session_id ) session.add(record_type_suggestion) @@ -80,7 +96,8 @@ async def run( agency_id_suggestions = [ AnonymousAnnotationAgency( url_id=url_id, - agency_id=agency_id + agency_id=agency_id, + session_id=session_id ) for agency_id in self.request.agency_ids ] @@ -91,7 +108,8 @@ async def run( location_id_suggestions = [ AnonymousAnnotationLocation( url_id=url_id, - location_id=location_id + location_id=location_id, + session_id=session_id ) for location_id in self.request.location_ids ] diff --git a/src/core/tasks/url/operators/validate/queries/ctes/counts/constants.py b/src/core/tasks/url/operators/validate/queries/ctes/counts/constants.py new file mode 100644 index 00000000..d09029a4 --- /dev/null +++ b/src/core/tasks/url/operators/validate/queries/ctes/counts/constants.py @@ -0,0 +1,3 @@ + + +ANONYMOUS_VOTE_RATIO = 0.5 \ No newline at end of file diff --git a/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/agency.py b/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/agency.py index 141393bd..36fe0a87 100644 --- a/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/agency.py +++ b/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/agency.py @@ -1,24 +1,66 @@ from sqlalchemy import select, func +from src.core.tasks.url.operators.validate.queries.ctes.counts.constants import ANONYMOUS_VOTE_RATIO from src.core.tasks.url.operators.validate.queries.ctes.counts.core import ValidatedCountsCTEContainer from src.db.models.impl.url.suggestion.agency.user import UserURLAgencySuggestion +from src.db.models.impl.url.suggestion.anonymous.agency.sqlalchemy import AnonymousAnnotationAgency from src.db.models.views.unvalidated_url import UnvalidatedURL +_user_counts = ( + select( + UserURLAgencySuggestion.url_id, + UserURLAgencySuggestion.agency_id.label("entity"), + func.count().label("votes") + ) + .group_by( + UserURLAgencySuggestion.url_id, + UserURLAgencySuggestion.agency_id + ) +) + +_anon_counts = ( + select( + AnonymousAnnotationAgency.url_id, + AnonymousAnnotationAgency.agency_id.label("entity"), + (func.count() / ANONYMOUS_VOTE_RATIO).label("votes") + ) + .group_by( + AnonymousAnnotationAgency.url_id, + AnonymousAnnotationAgency.agency_id + ) +) + +_union_counts = ( + select( + _user_counts.c.url_id, + _user_counts.c.entity, + _user_counts.c.votes + ) + .union_all( + select( + _anon_counts.c.url_id, + _anon_counts.c.entity, + _anon_counts.c.votes + ) + ) + .cte("counts_agency_union") +) + AGENCY_VALIDATION_COUNTS_CTE = ValidatedCountsCTEContainer( ( - select( - UserURLAgencySuggestion.url_id, - UserURLAgencySuggestion.agency_id.label("entity"), - func.count().label("votes") - ) - .join( - UnvalidatedURL, - UserURLAgencySuggestion.url_id == UnvalidatedURL.url_id - ) - .group_by( - UserURLAgencySuggestion.url_id, - UserURLAgencySuggestion.agency_id - ) - .cte("counts_agency") + select( + _union_counts.c.url_id, + _union_counts.c.entity, + func.sum(_union_counts.c.votes).label("votes") + ) + .join( + UnvalidatedURL, + _union_counts.c.url_id == UnvalidatedURL.url_id + ) + .group_by( + _union_counts.c.url_id, + _union_counts.c.entity, ) + .cte("counts_agency") + ) ) \ No newline at end of file diff --git a/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/location.py b/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/location.py index 2ef385cc..4e180e18 100644 --- a/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/location.py +++ b/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/location.py @@ -1,24 +1,67 @@ from sqlalchemy import select, func +from src.core.tasks.url.operators.validate.queries.ctes.counts.constants import ANONYMOUS_VOTE_RATIO from src.core.tasks.url.operators.validate.queries.ctes.counts.core import ValidatedCountsCTEContainer +from src.db.models.impl.url.suggestion.anonymous.location.sqlalchemy import AnonymousAnnotationLocation +from src.db.models.impl.url.suggestion.anonymous.url_type.sqlalchemy import AnonymousAnnotationURLType from src.db.models.impl.url.suggestion.location.user.sqlalchemy import UserLocationSuggestion from src.db.models.views.unvalidated_url import UnvalidatedURL +_user_counts = ( + select( + UserLocationSuggestion.url_id, + UserLocationSuggestion.location_id.label("entity"), + func.count().label("votes") + ) + .group_by( + UserLocationSuggestion.url_id, + UserLocationSuggestion.location_id + ) +) + +_anon_counts = ( + select( + AnonymousAnnotationLocation.url_id, + AnonymousAnnotationLocation.location_id.label("entity"), + (func.count() / ANONYMOUS_VOTE_RATIO).label("votes") + ) + .group_by( + AnonymousAnnotationLocation.url_id, + AnonymousAnnotationLocation.location_id + ) +) + +_union_counts = ( + select( + _user_counts.c.url_id, + _user_counts.c.entity, + _user_counts.c.votes + ) + .union_all( + select( + _anon_counts.c.url_id, + _anon_counts.c.entity, + _anon_counts.c.votes + ) + ) + .cte("counts_location_union") +) + LOCATION_VALIDATION_COUNTS_CTE = ValidatedCountsCTEContainer( ( - select( - UserLocationSuggestion.url_id, - UserLocationSuggestion.location_id.label("entity"), - func.count().label("votes") - ) - .join( - UnvalidatedURL, - UserLocationSuggestion.url_id == UnvalidatedURL.url_id - ) - .group_by( - UserLocationSuggestion.url_id, - UserLocationSuggestion.location_id - ) - .cte("counts_location") + select( + _union_counts.c.url_id, + _union_counts.c.entity, + func.sum(_union_counts.c.votes).label("votes") + ) + .join( + UnvalidatedURL, + _union_counts.c.url_id == UnvalidatedURL.url_id + ) + .group_by( + _union_counts.c.url_id, + _union_counts.c.entity, ) + .cte("counts_location") + ) ) \ No newline at end of file diff --git a/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/record_type.py b/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/record_type.py index 6300ec92..65b1f9b0 100644 --- a/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/record_type.py +++ b/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/record_type.py @@ -1,23 +1,66 @@ from sqlalchemy import select, func +from src.core.tasks.url.operators.validate.queries.ctes.counts.constants import ANONYMOUS_VOTE_RATIO from src.core.tasks.url.operators.validate.queries.ctes.counts.core import ValidatedCountsCTEContainer +from src.db.models.impl.url.suggestion.anonymous.record_type.sqlalchemy import AnonymousAnnotationRecordType from src.db.models.impl.url.suggestion.record_type.user import UserRecordTypeSuggestion from src.db.models.views.unvalidated_url import UnvalidatedURL +_user_counts = ( + select( + UserRecordTypeSuggestion.url_id, + UserRecordTypeSuggestion.record_type.label("entity"), + func.count().label("votes") + ) + .group_by( + UserRecordTypeSuggestion.url_id, + UserRecordTypeSuggestion.record_type + ) +) + +_anon_counts = ( + select( + AnonymousAnnotationRecordType.url_id, + AnonymousAnnotationRecordType.record_type.label("entity"), + (func.count() * ANONYMOUS_VOTE_RATIO).label("votes") + ) + .group_by( + AnonymousAnnotationRecordType.url_id, + AnonymousAnnotationRecordType.record_type + ) +) + +_union_counts = ( + select( + _user_counts.c.url_id, + _user_counts.c.entity, + _user_counts.c.votes + ) + .union_all( + select( + _anon_counts.c.url_id, + _anon_counts.c.entity, + _anon_counts.c.votes + ) + ) + .cte("counts_record_type_union") +) + + RECORD_TYPE_COUNTS_CTE = ValidatedCountsCTEContainer( ( select( - UserRecordTypeSuggestion.url_id, - UserRecordTypeSuggestion.record_type.label("entity"), - func.count().label("votes") + _union_counts.c.url_id, + _union_counts.c.entity, + func.sum(_union_counts.c.votes).label("votes") ) .join( UnvalidatedURL, - UserRecordTypeSuggestion.url_id == UnvalidatedURL.url_id + _union_counts.c.url_id == UnvalidatedURL.url_id ) .group_by( - UserRecordTypeSuggestion.url_id, - UserRecordTypeSuggestion.record_type + _union_counts.c.url_id, + _union_counts.c.entity, ) .cte("counts_record_type") ) diff --git a/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/url_type.py b/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/url_type.py index f0d340e7..72638f19 100644 --- a/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/url_type.py +++ b/src/core/tasks/url/operators/validate/queries/ctes/counts/impl/url_type.py @@ -1,23 +1,65 @@ from sqlalchemy import select, func +from src.core.tasks.url.operators.validate.queries.ctes.counts.constants import ANONYMOUS_VOTE_RATIO from src.core.tasks.url.operators.validate.queries.ctes.counts.core import ValidatedCountsCTEContainer +from src.db.models.impl.url.suggestion.anonymous.url_type.sqlalchemy import AnonymousAnnotationURLType from src.db.models.impl.url.suggestion.url_type.user import UserURLTypeSuggestion from src.db.models.views.unvalidated_url import UnvalidatedURL +_user_counts = ( + select( + UserURLTypeSuggestion.url_id, + UserURLTypeSuggestion.type.label("entity"), + func.count().label("votes") + ) + .group_by( + UserURLTypeSuggestion.url_id, + UserURLTypeSuggestion.type + ) +) + +_anon_counts = ( + select( + AnonymousAnnotationURLType.url_id, + AnonymousAnnotationURLType.url_type.label("entity"), + (func.count() / ANONYMOUS_VOTE_RATIO).label("votes") + ) + .group_by( + AnonymousAnnotationURLType.url_id, + AnonymousAnnotationURLType.url_type + ) +) + +_union_counts = ( + select( + _user_counts.c.url_id, + _user_counts.c.entity, + _user_counts.c.votes + ) + .union_all( + select( + _anon_counts.c.url_id, + _anon_counts.c.entity, + _anon_counts.c.votes + ) + ) + .cte("counts_url_type_union") +) + URL_TYPES_VALIDATION_COUNTS_CTE = ValidatedCountsCTEContainer( ( select( - UserURLTypeSuggestion.url_id, - UserURLTypeSuggestion.type.label("entity"), - func.count().label("votes") + _union_counts.c.url_id, + _union_counts.c.entity, + func.sum(_union_counts.c.votes).label("votes") ) .join( UnvalidatedURL, - UserURLTypeSuggestion.url_id == UnvalidatedURL.url_id + _union_counts.c.url_id == UnvalidatedURL.url_id ) .group_by( - UserURLTypeSuggestion.url_id, - UserURLTypeSuggestion.type + _union_counts.c.url_id, + _union_counts.c.entity, ) .cte("counts_url_type") ) diff --git a/src/db/client/async_.py b/src/db/client/async_.py index 5ec64ad7..125c594e 100644 --- a/src/db/client/async_.py +++ b/src/db/client/async_.py @@ -1,6 +1,7 @@ from datetime import datetime from functools import wraps from typing import Optional, Any, List +from uuid import UUID, uuid4 from sqlalchemy import select, func, Select, and_, update, Row, text from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker, AsyncEngine @@ -77,6 +78,7 @@ from src.db.models.impl.url.html.content.sqlalchemy import URLHTMLContent from src.db.models.impl.url.optional_ds_metadata.sqlalchemy import URLOptionalDataSourceMetadata from src.db.models.impl.url.suggestion.agency.user import UserURLAgencySuggestion +from src.db.models.impl.url.suggestion.anonymous import AnonymousSession from src.db.models.impl.url.suggestion.record_type.auto import AutoRecordTypeSuggestion from src.db.models.impl.url.suggestion.record_type.user import UserRecordTypeSuggestion from src.db.models.impl.url.suggestion.url_type.auto.pydantic.input import AutoRelevancyAnnotationInput diff --git a/src/db/models/impl/url/suggestion/anonymous/__init__.py b/src/db/models/impl/url/suggestion/anonymous/__init__.py index e69de29b..fddc715f 100644 --- a/src/db/models/impl/url/suggestion/anonymous/__init__.py +++ b/src/db/models/impl/url/suggestion/anonymous/__init__.py @@ -0,0 +1 @@ +from src.db.models.impl.url.suggestion.anonymous.session.sqlalchemy import AnonymousSession \ No newline at end of file diff --git a/src/db/models/impl/url/suggestion/anonymous/agency/sqlalchemy.py b/src/db/models/impl/url/suggestion/anonymous/agency/sqlalchemy.py index afea2f23..6f750289 100644 --- a/src/db/models/impl/url/suggestion/anonymous/agency/sqlalchemy.py +++ b/src/db/models/impl/url/suggestion/anonymous/agency/sqlalchemy.py @@ -1,6 +1,6 @@ from sqlalchemy import PrimaryKeyConstraint -from src.db.models.mixins import URLDependentMixin, AgencyDependentMixin, CreatedAtMixin +from src.db.models.mixins import URLDependentMixin, AgencyDependentMixin, CreatedAtMixin, AnonymousSessionMixin from src.db.models.templates_.base import Base @@ -8,9 +8,10 @@ class AnonymousAnnotationAgency( Base, URLDependentMixin, AgencyDependentMixin, - CreatedAtMixin + CreatedAtMixin, + AnonymousSessionMixin ): __tablename__ = "anonymous_annotation_agency" __table_args__ = ( - PrimaryKeyConstraint("url_id", "agency_id"), + PrimaryKeyConstraint("session_id", "url_id", "agency_id"), ) \ No newline at end of file diff --git a/src/db/models/impl/url/suggestion/anonymous/location/sqlalchemy.py b/src/db/models/impl/url/suggestion/anonymous/location/sqlalchemy.py index f02cb7ba..3e39810b 100644 --- a/src/db/models/impl/url/suggestion/anonymous/location/sqlalchemy.py +++ b/src/db/models/impl/url/suggestion/anonymous/location/sqlalchemy.py @@ -1,6 +1,6 @@ from sqlalchemy import PrimaryKeyConstraint -from src.db.models.mixins import LocationDependentMixin, URLDependentMixin, CreatedAtMixin +from src.db.models.mixins import LocationDependentMixin, URLDependentMixin, CreatedAtMixin, AnonymousSessionMixin from src.db.models.templates_.base import Base @@ -8,10 +8,11 @@ class AnonymousAnnotationLocation( Base, URLDependentMixin, LocationDependentMixin, - CreatedAtMixin + CreatedAtMixin, + AnonymousSessionMixin ): __tablename__ = "anonymous_annotation_location" __table_args__ = ( - PrimaryKeyConstraint("url_id", "location_id"), + PrimaryKeyConstraint("session_id", "url_id", "location_id"), ) \ No newline at end of file diff --git a/src/db/models/impl/url/suggestion/anonymous/record_type/sqlalchemy.py b/src/db/models/impl/url/suggestion/anonymous/record_type/sqlalchemy.py index 25a9ddec..22f37839 100644 --- a/src/db/models/impl/url/suggestion/anonymous/record_type/sqlalchemy.py +++ b/src/db/models/impl/url/suggestion/anonymous/record_type/sqlalchemy.py @@ -3,18 +3,19 @@ from src.core.enums import RecordType from src.db.models.helpers import enum_column -from src.db.models.mixins import URLDependentMixin, CreatedAtMixin +from src.db.models.mixins import URLDependentMixin, CreatedAtMixin, AnonymousSessionMixin from src.db.models.templates_.base import Base class AnonymousAnnotationRecordType( Base, URLDependentMixin, - CreatedAtMixin + CreatedAtMixin, + AnonymousSessionMixin ): __tablename__ = "anonymous_annotation_record_type" __table_args__ = ( - PrimaryKeyConstraint("url_id", "record_type"), + PrimaryKeyConstraint("session_id", "url_id", "record_type"), ) record_type: Mapped[RecordType] = enum_column( diff --git a/src/db/models/impl/url/suggestion/anonymous/session/__init__.py b/src/db/models/impl/url/suggestion/anonymous/session/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/db/models/impl/url/suggestion/anonymous/session/sqlalchemy.py b/src/db/models/impl/url/suggestion/anonymous/session/sqlalchemy.py new file mode 100644 index 00000000..cbb43448 --- /dev/null +++ b/src/db/models/impl/url/suggestion/anonymous/session/sqlalchemy.py @@ -0,0 +1,17 @@ +from sqlalchemy import text, Column + +from src.db.models.mixins import CreatedAtMixin +from src.db.models.templates_.base import Base +from sqlalchemy.dialects.postgresql import UUID + + +class AnonymousSession( + Base, + CreatedAtMixin +): + __tablename__ = "anonymous_sessions" + id = Column( + UUID(as_uuid=True), + primary_key=True, + server_default=text("gen_random_uuid()") + ) \ No newline at end of file diff --git a/src/db/models/impl/url/suggestion/anonymous/url_type/sqlalchemy.py b/src/db/models/impl/url/suggestion/anonymous/url_type/sqlalchemy.py index f9033ffa..f0cbc6a7 100644 --- a/src/db/models/impl/url/suggestion/anonymous/url_type/sqlalchemy.py +++ b/src/db/models/impl/url/suggestion/anonymous/url_type/sqlalchemy.py @@ -3,18 +3,19 @@ from src.db.models.helpers import enum_column from src.db.models.impl.flag.url_validated.enums import URLType -from src.db.models.mixins import URLDependentMixin, CreatedAtMixin +from src.db.models.mixins import URLDependentMixin, CreatedAtMixin, AnonymousSessionMixin from src.db.models.templates_.base import Base class AnonymousAnnotationURLType( Base, URLDependentMixin, - CreatedAtMixin + CreatedAtMixin, + AnonymousSessionMixin ): __tablename__ = "anonymous_annotation_url_type" __table_args__ = ( - PrimaryKeyConstraint("url_id", "url_type"), + PrimaryKeyConstraint("session_id", "url_id", "url_type"), ) url_type: Mapped[URLType] = enum_column( diff --git a/src/db/models/mixins.py b/src/db/models/mixins.py index 7a7d6460..640ec955 100644 --- a/src/db/models/mixins.py +++ b/src/db/models/mixins.py @@ -5,6 +5,7 @@ from src.db.models.exceptions import WriteToViewError from src.db.models.helpers import get_created_at_column, CURRENT_TIME_SERVER_DEFAULT, url_id_primary_key_constraint, \ VIEW_ARG +from sqlalchemy.dialects.postgresql import UUID class URLDependentMixin: @@ -96,4 +97,14 @@ class URLDependentViewMixin(URLDependentMixin, ViewMixin): __table_args__ = ( url_id_primary_key_constraint(), VIEW_ARG + ) + +class AnonymousSessionMixin: + session_id = Column( + UUID(as_uuid=True), + ForeignKey( + 'anonymous_sessions.id', + ondelete="CASCADE", + ), + nullable=False ) \ No newline at end of file diff --git a/src/db/queries/implementations/anonymous_session.py b/src/db/queries/implementations/anonymous_session.py new file mode 100644 index 00000000..0ff00ea3 --- /dev/null +++ b/src/db/queries/implementations/anonymous_session.py @@ -0,0 +1,16 @@ +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.impl.url.suggestion.anonymous import AnonymousSession +from src.db.queries.base.builder import QueryBuilderBase + + +class MakeAnonymousSessionQueryBuilder(QueryBuilderBase): + + async def run(self, session: AsyncSession) -> UUID: + return await self.sh.add( + session=session, + model=AnonymousSession(), + return_id=True + ) diff --git a/tests/automated/integration/api/annotate/anonymous/helper.py b/tests/automated/integration/api/annotate/anonymous/helper.py index ccfe518f..cb892091 100644 --- a/tests/automated/integration/api/annotate/anonymous/helper.py +++ b/tests/automated/integration/api/annotate/anonymous/helper.py @@ -1,23 +1,32 @@ -from src.api.endpoints.annotate.all.get.models.response import GetNextURLForAllAnnotationResponse +from uuid import UUID + from src.api.endpoints.annotate.all.post.models.request import AllAnnotationPostInfo +from src.api.endpoints.annotate.anonymous.get.response import GetNextURLForAnonymousAnnotationResponse from tests.automated.integration.api._helpers.RequestValidator import RequestValidator async def get_next_url_for_anonymous_annotation( request_validator: RequestValidator, -): + session_id: UUID | None = None +) -> GetNextURLForAnonymousAnnotationResponse: + url = "/annotate/anonymous" + if session_id is not None: + url += f"?session_id={session_id}" + data = request_validator.get( - url=f"/annotate/anonymous" + url=url ) - return GetNextURLForAllAnnotationResponse(**data) + return GetNextURLForAnonymousAnnotationResponse(**data) async def post_and_get_next_url_for_anonymous_annotation( request_validator: RequestValidator, url_id: int, all_annotation_post_info: AllAnnotationPostInfo, -): + session_id: UUID +) -> GetNextURLForAnonymousAnnotationResponse: + url = f"/annotate/anonymous/{url_id}?session_id={session_id}" data = request_validator.post( - url=f"/annotate/anonymous/{url_id}", + url=url, json=all_annotation_post_info.model_dump(mode='json') ) - return GetNextURLForAllAnnotationResponse(**data) \ No newline at end of file + return GetNextURLForAnonymousAnnotationResponse(**data) \ No newline at end of file diff --git a/tests/automated/integration/api/annotate/anonymous/test_core.py b/tests/automated/integration/api/annotate/anonymous/test_core.py index 84781768..b6fb93fa 100644 --- a/tests/automated/integration/api/annotate/anonymous/test_core.py +++ b/tests/automated/integration/api/annotate/anonymous/test_core.py @@ -1,3 +1,5 @@ +from uuid import UUID + import pytest from src.api.endpoints.annotate.all.get.models.name import NameAnnotationSuggestion @@ -6,6 +8,7 @@ from src.api.endpoints.annotate.all.post.models.location import AnnotationPostLocationInfo from src.api.endpoints.annotate.all.post.models.name import AnnotationPostNameInfo from src.api.endpoints.annotate.all.post.models.request import AllAnnotationPostInfo +from src.api.endpoints.annotate.anonymous.get.response import GetNextURLForAnonymousAnnotationResponse from src.core.enums import RecordType from src.db.dtos.url.mapping_.simple import SimpleURLMapping from src.db.models.impl.flag.url_validated.enums import URLType @@ -27,10 +30,6 @@ async def test_annotate_anonymous( pennsylvania: USStateCreationInfo, ): - # TODO: Update to include session ID - - # TODO: If session ID not included, user gets same annotation as before? - ath = api_test_helper ddc = ath.db_data_creator rv = ath.request_validator @@ -45,7 +44,9 @@ async def test_annotate_anonymous( ) url_mapping_2: SimpleURLMapping = setup_info_2.url_mapping - get_response_1: GetNextURLForAllAnnotationResponse = await get_next_url_for_anonymous_annotation(rv) + get_response_1: GetNextURLForAnonymousAnnotationResponse = await get_next_url_for_anonymous_annotation(rv) + session_id: UUID = get_response_1.session_id + assert session_id is not None assert get_response_1.next_annotation is not None assert len(get_response_1.next_annotation.name_suggestions) == 1 name_suggestion: NameAnnotationSuggestion = get_response_1.next_annotation.name_suggestions[0] @@ -54,7 +55,7 @@ async def test_annotate_anonymous( agency_id: int = await ddc.agency() - post_response_1: GetNextURLForAllAnnotationResponse = await post_and_get_next_url_for_anonymous_annotation( + post_response_1: GetNextURLForAnonymousAnnotationResponse = await post_and_get_next_url_for_anonymous_annotation( rv, get_response_1.next_annotation.url_info.url_id, AllAnnotationPostInfo( @@ -69,8 +70,11 @@ async def test_annotate_anonymous( name_info=AnnotationPostNameInfo( new_name="New Name" ) - ) + ), + session_id=session_id ) + assert post_response_1.session_id == session_id + assert post_response_1.next_annotation is not None assert post_response_1.next_annotation.url_info.url_id != get_response_1.next_annotation.url_info.url_id @@ -86,3 +90,15 @@ async def test_annotate_anonymous( instance: model = instances[0] assert instance.url_id == get_response_1.next_annotation.url_info.url_id + # Run again without giving session ID, confirm original URL returned + get_response_2: GetNextURLForAnonymousAnnotationResponse = await get_next_url_for_anonymous_annotation(rv) + assert get_response_2.session_id != session_id + assert get_response_2.next_annotation is not None + assert get_response_2.next_annotation.url_info.url_id == get_response_1.next_annotation.url_info.url_id + + # Run again while giving session ID, confirm second URL returned + get_response_3: GetNextURLForAnonymousAnnotationResponse = await get_next_url_for_anonymous_annotation(rv, session_id) + assert get_response_3.session_id == session_id + assert get_response_3.next_annotation is not None + assert get_response_3.next_annotation.url_info.url_id == post_response_1.next_annotation.url_info.url_id + diff --git a/tests/automated/integration/api/submit/data_source/test_core.py b/tests/automated/integration/api/submit/data_source/test_core.py index eed0cd00..558327c3 100644 --- a/tests/automated/integration/api/submit/data_source/test_core.py +++ b/tests/automated/integration/api/submit/data_source/test_core.py @@ -1,4 +1,5 @@ from datetime import date +from uuid import UUID import pytest @@ -7,6 +8,7 @@ from src.core.enums import RecordType, BatchStatus from src.db.client.async_ import AsyncDatabaseClient from src.db.models.impl.batch.sqlalchemy import Batch +from src.db.models.impl.flag.url_validated.enums import URLType from src.db.models.impl.link.batch_url.sqlalchemy import LinkBatchURL from src.db.models.impl.url.core.enums import URLSource from src.db.models.impl.url.core.sqlalchemy import URL @@ -15,6 +17,8 @@ from src.db.models.impl.url.optional_ds_metadata.sqlalchemy import URLOptionalDataSourceMetadata from src.db.models.impl.url.suggestion.anonymous.agency.sqlalchemy import AnonymousAnnotationAgency from src.db.models.impl.url.suggestion.anonymous.location.sqlalchemy import AnonymousAnnotationLocation +from src.db.models.impl.url.suggestion.anonymous.record_type.sqlalchemy import AnonymousAnnotationRecordType +from src.db.models.impl.url.suggestion.anonymous.url_type.sqlalchemy import AnonymousAnnotationURLType from src.db.models.impl.url.suggestion.name.sqlalchemy import URLNameSuggestion from tests.helpers.api_test_helper import APITestHelper from tests.helpers.data_creator.models.creation_info.locality import LocalityCreationInfo @@ -91,21 +95,35 @@ async def test_submit_data_source( assert batch_url_link.batch_id == batch.id assert batch_url_link.url_id == url.id + # Check for anonymous annotations + url_type_suggestion: AnonymousAnnotationURLType = await adb_client.one_or_none_model(AnonymousAnnotationURLType) + assert url_type_suggestion is not None + assert url_type_suggestion.url_id == url.id + assert url_type_suggestion.url_type == URLType.DATA_SOURCE + session_id: UUID = url_type_suggestion.session_id + # Check for Location Suggestion location_suggestion: AnonymousAnnotationLocation = await adb_client.one_or_none_model(AnonymousAnnotationLocation) assert location_suggestion is not None assert location_suggestion.location_id == pittsburgh_locality.location_id + assert location_suggestion.session_id == session_id # Check for Agency Suggestion agency_suggestion: AnonymousAnnotationAgency = await adb_client.one_or_none_model(AnonymousAnnotationAgency) assert agency_suggestion is not None assert agency_suggestion.agency_id == test_agency_id + assert agency_suggestion.session_id == session_id # Check for Name Suggestion name_suggestion: URLNameSuggestion = await adb_client.one_or_none_model(URLNameSuggestion) assert name_suggestion is not None assert name_suggestion.suggestion == "Example name" + # Check for Record Type Suggestion + record_type_suggestion: AnonymousAnnotationRecordType = await adb_client.one_or_none_model(AnonymousAnnotationRecordType) + assert record_type_suggestion.record_type == RecordType.COMPLAINTS_AND_MISCONDUCT + assert record_type_suggestion.session_id == session_id + # Check for URL DS Optional Metadata optional_ds: URLOptionalDataSourceMetadata = await adb_client.one_or_none_model(URLOptionalDataSourceMetadata) assert optional_ds is not None diff --git a/tests/automated/integration/api/url/by_id/delete/test_any_url.py b/tests/automated/integration/api/url/by_id/delete/test_any_url.py index bd17141b..50b3ca0c 100644 --- a/tests/automated/integration/api/url/by_id/delete/test_any_url.py +++ b/tests/automated/integration/api/url/by_id/delete/test_any_url.py @@ -1,3 +1,5 @@ +from uuid import UUID + import pytest from sqlalchemy import select @@ -44,6 +46,7 @@ from src.db.models.impl.url.suggestion.url_type.user import UserURLTypeSuggestion from src.db.models.impl.url.task_error.sqlalchemy import URLTaskError from src.db.models.impl.url.web_metadata.sqlalchemy import URLWebMetadata +from src.db.queries.implementations.anonymous_session import MakeAnonymousSessionQueryBuilder from tests.helpers.api_test_helper import APITestHelper from tests.helpers.data_creator.core import DBDataCreator from tests.helpers.data_creator.models.creation_info.locality import LocalityCreationInfo @@ -414,27 +417,34 @@ async def _setup( user_id=1, ) ) + session_id: UUID = await dbc.run_query_builder( + MakeAnonymousSessionQueryBuilder() + ) ## ANONYMOUS for model in [ ### Agency AnonymousAnnotationAgency( url_id=url.url_id, - agency_id=agency_id + agency_id=agency_id, + session_id=session_id, ), ### Record Type AnonymousAnnotationRecordType( url_id=url.url_id, - record_type=RecordType.BOOKING_REPORTS.value + record_type=RecordType.BOOKING_REPORTS.value, + session_id=session_id, ), ### URL Type AnonymousAnnotationURLType( url_id=url.url_id, - url_type=URLType.INDIVIDUAL_RECORD + url_type=URLType.INDIVIDUAL_RECORD, + session_id=session_id, ), ### Location AnonymousAnnotationLocation( url_id=url.url_id, - location_id=pittsburgh_id + location_id=pittsburgh_id, + session_id=session_id ) ]: await dbc.add(model) diff --git a/tests/automated/integration/tasks/url/impl/validate/helper.py b/tests/automated/integration/tasks/url/impl/validate/helper.py index 6ab44984..879fbc66 100644 --- a/tests/automated/integration/tasks/url/impl/validate/helper.py +++ b/tests/automated/integration/tasks/url/impl/validate/helper.py @@ -1,3 +1,5 @@ +from uuid import UUID + from src.api.endpoints.annotate.agency.post.dto import URLAgencyAnnotationPostInfo from src.core.enums import RecordType from src.db.client.async_ import AsyncDatabaseClient @@ -8,6 +10,7 @@ from src.db.models.impl.url.core.sqlalchemy import URL from src.db.models.impl.url.record_type.sqlalchemy import URLRecordType from src.db.models.impl.url.suggestion.name.enums import NameSuggestionSource +from src.db.queries.implementations.anonymous_session import MakeAnonymousSessionQueryBuilder from tests.conftest import db_data_creator from tests.helpers.counter import next_int from tests.helpers.data_creator.core import DBDataCreator @@ -95,6 +98,11 @@ async def add_agency_suggestions( ) ) + async def get_anonymous_session_id(self) -> UUID: + return await self.adb_client.run_query_builder( + MakeAnonymousSessionQueryBuilder() + ) + async def add_location_suggestions( self, count: int = 1, diff --git a/tests/automated/integration/tasks/url/impl/validate/test_data_source.py b/tests/automated/integration/tasks/url/impl/validate/test_data_source.py index 82bed288..4fe0d444 100644 --- a/tests/automated/integration/tasks/url/impl/validate/test_data_source.py +++ b/tests/automated/integration/tasks/url/impl/validate/test_data_source.py @@ -6,12 +6,18 @@ - URL Type (DATA SOURCE) And confirm it is validated as DATA SOURCE """ +from uuid import UUID + import pytest from src.core.enums import RecordType from src.core.tasks.url.operators.validate.core import AutoValidateURLTaskOperator from src.db.models.impl.flag.url_validated.enums import URLType -from tests.automated.integration.tasks.url.impl.validate.helper import TestValidateTaskHelper +from src.db.models.impl.url.suggestion.anonymous.agency.sqlalchemy import AnonymousAnnotationAgency +from src.db.models.impl.url.suggestion.anonymous.location.sqlalchemy import AnonymousAnnotationLocation +from src.db.models.impl.url.suggestion.anonymous.record_type.sqlalchemy import AnonymousAnnotationRecordType +from src.db.models.impl.url.suggestion.anonymous.url_type.sqlalchemy import AnonymousAnnotationURLType +from tests.automated.integration.tasks.url.impl.validate.helper import TestValidateTaskHelper, DEFAULT_RECORD_TYPE from tests.helpers.run import run_task_and_confirm_success @@ -27,20 +33,55 @@ async def test_data_source( assert not await operator.meets_task_prerequisites() - await helper.add_agency_suggestions(count=2) + await helper.add_agency_suggestions(count=1) assert not await operator.meets_task_prerequisites() - await helper.add_location_suggestions(count=2) + await helper.add_location_suggestions(count=1) assert not await operator.meets_task_prerequisites() - await helper.add_record_type_suggestions(count=2) + await helper.add_record_type_suggestions(count=1) assert not await operator.meets_task_prerequisites() await helper.add_name_suggestion(count=2) + assert not await operator.meets_task_prerequisites() + + # Add anonymous annotations + session_id_1: UUID = await helper.get_anonymous_session_id() + session_id_2: UUID = await helper.get_anonymous_session_id() + + for session_id in [session_id_1, session_id_2]: + anon_url_type = AnonymousAnnotationURLType( + url_type=URLType.DATA_SOURCE, + session_id=session_id, + url_id=helper.url_id + ) + anon_record_type = AnonymousAnnotationRecordType( + record_type=DEFAULT_RECORD_TYPE, + session_id=session_id, + url_id=helper.url_id + ) + anon_location = AnonymousAnnotationLocation( + location_id=helper.location_id, + session_id=session_id, + url_id=helper.url_id + ) + anon_agency = AnonymousAnnotationAgency( + agency_id=helper.agency_id, + session_id=session_id, + url_id=helper.url_id + ) + for model in [ + anon_url_type, + anon_record_type, + anon_location, + anon_agency + ]: + await helper.adb_client.add(model) + assert await operator.meets_task_prerequisites() # Add different record type suggestion @@ -52,8 +93,14 @@ async def test_data_source( # Assert no longer meets task prerequisites assert not await operator.meets_task_prerequisites() - # Add tiebreaker - await helper.add_record_type_suggestions() + # Add tiebreaker -- a single anonymous vote + session_id_3: UUID = await helper.get_anonymous_session_id() + anon_record_type = AnonymousAnnotationRecordType( + record_type=DEFAULT_RECORD_TYPE, + session_id=session_id_3, + url_id=helper.url_id + ) + await helper.adb_client.add(anon_record_type) assert await operator.meets_task_prerequisites()