From 7751e1a79dfcb7e2c0670bc008a1299dbccc5f55 Mon Sep 17 00:00:00 2001 From: Tim Repke <repke@mcc-berlin.net> Date: Mon, 2 Sep 2024 21:18:35 +0200 Subject: [PATCH] session updates and news endoint --- .gitignore | 3 +- README.md | 13 ++++++ requirements.txt | 2 +- server/api/routes/annotations.py | 12 +++--- server/api/routes/evaluation.py | 3 +- server/api/routes/imports.py | 4 +- server/api/routes/mailing.py | 57 +++++++++++++++++++++++++-- server/api/routes/project/__init__.py | 2 +- server/api/routes/search.py | 6 +-- server/pipelines/actor.py | 2 +- server/util/email.py | 27 ++++++++----- 11 files changed, 103 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 3d8aad4..371dd99 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ __pycache__ server.md dumps/ scratch/ -.tasks/ \ No newline at end of file +.tasks/ +volumes \ No newline at end of file diff --git a/README.md b/README.md index 62f2d55..8d8e9f5 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,16 @@ FinalKillSignal=SIGKILL [Install] WantedBy=multi-user.target ``` + +## Testing database things in the console +``` +from sqlalchemy import select +from nacsos_data.db import get_engine +from nacsos_data.db.schemas.users import User +engine = get_engine('config/remote.env') +with engine.session() as session: + users = session.execute(select(User.email, User.full_name, User.username) + .where(User.setting_newsletter == False, + User.is_active == True)).mappings().all() + print(users[0]) +``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0fa3349..cb448e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -r requirements_base.txt -nacsos_data[utils,scripts] @ git+ssh://git@gitlab.pik-potsdam.de/mcc-apsis/nacsos/nacsos-data.git@v0.16.5 \ No newline at end of file +nacsos_data[utils,scripts] @ git+ssh://git@gitlab.pik-potsdam.de/mcc-apsis/nacsos/nacsos-data.git@v0.17.0 \ No newline at end of file diff --git a/server/api/routes/annotations.py b/server/api/routes/annotations.py index e71d3e9..a242d71 100644 --- a/server/api/routes/annotations.py +++ b/server/api/routes/annotations.py @@ -137,7 +137,7 @@ async def put_annotation_scheme(annotation_scheme: AnnotationSchemeModel, @router.delete('/schemes/definition/{scheme_id}') async def remove_annotation_scheme(annotation_scheme_id: str, permissions=Depends(UserPermissionChecker('annotations_edit'))) -> None: - await delete_annotation_scheme(annotation_scheme_id=annotation_scheme_id, db_engine=db_engine) + await delete_annotation_scheme(annotation_scheme_id=annotation_scheme_id, db_engine=db_engine, use_commit=True) @router.get('/schemes/list/{project_id}', response_model=list[AnnotationSchemeModel]) @@ -272,7 +272,7 @@ async def put_assignment_scope(assignment_scope: AssignmentScopeModel, async def remove_assignment_scope(assignment_scope_id: str, permissions=Depends(UserPermissionChecker('annotations_edit'))) -> None: try: - await delete_assignment_scope(assignment_scope_id=assignment_scope_id, db_engine=db_engine) + await delete_assignment_scope(assignment_scope_id=assignment_scope_id, db_engine=db_engine, use_commit=True) except ValueError as e: raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(e)) @@ -409,7 +409,7 @@ async def make_assignments(payload: MakeAssignmentsRequestModel, detail=f'Method "{payload.config.config_type}" is unknown.') if payload.save: - await store_assignments(assignments=assignments, db_engine=db_engine) + await store_assignments(assignments=assignments, db_engine=db_engine, use_commit=True) return assignments @@ -441,6 +441,7 @@ async def clear_empty_assignments(scope_id: str, WHERE cnt = 0 );''') await session.execute(stmt, {'scope_id': scope_id}) + await session.commit() class AssignmentEditInfo(BaseModel): @@ -593,7 +594,7 @@ async def save_resolved_annotations(settings: BotMetaResolveBase, assignment_scope_id: str, annotation_scheme_id: str, permissions=Depends(UserPermissionChecker('annotations_edit'))): - meta_id = await store_resolved_bot_annotations(db_engine=db_engine, + meta_id = await store_resolved_bot_annotations(db_engine=db_engine, use_commit=True, project_id=permissions.permissions.project_id, assignment_scope_id=assignment_scope_id, annotation_scheme_id=annotation_scheme_id, @@ -612,7 +613,7 @@ async def update_resolved_annotations(bot_annotation_metadata_id: str, permissions=Depends(UserPermissionChecker('annotations_edit'))) -> None: # TODO: allow update of filters and settings? await update_resolved_bot_annotations(bot_annotation_metadata_id=bot_annotation_metadata_id, - name=name, matrix=matrix, db_engine=db_engine) + name=name, matrix=matrix, db_engine=db_engine, use_commit=True) @router.get('/config/resolved-list/', response_model=list[BotAnnotationMetaDataBaseModel]) @@ -649,6 +650,7 @@ async def delete_saved_resolved_annotations(bot_annotation_metadata_id: str, .scalars().one_or_none() if meta is not None: await session.delete(meta) + await session.commit() # TODO: do we need to commit? # TODO: ensure bot_annotations are deleted via cascade diff --git a/server/api/routes/evaluation.py b/server/api/routes/evaluation.py index 2983433..d23718d 100644 --- a/server/api/routes/evaluation.py +++ b/server/api/routes/evaluation.py @@ -140,7 +140,7 @@ async def update_tracker(tracker_id: str, # Update labels tracker.labels = batched_sequence - await session.flush() + await session.commit() # We are not handing over the existing tracker ORM, because the session is not persistent background_tasks.add_task(bg_populate_tracker, tracker_id, tracker.batch_size, diff) @@ -186,6 +186,7 @@ async def bg_populate_tracker(tracker_id: str, batch_size: int | None = None, la tracker.buscar = tracker.buscar + [(x, y)] # save after each step, so the user can refresh the page and get data as it becomes available await session.flush() + await session.commit() @router.get('/quality/load/{assignment_scope_id}', response_model=list[AnnotationQualityModel]) diff --git a/server/api/routes/imports.py b/server/api/routes/imports.py index 244de7c..45d6b05 100644 --- a/server/api/routes/imports.py +++ b/server/api/routes/imports.py @@ -49,7 +49,7 @@ async def put_import_details(import_details: ImportModel, permissions: UserPermissions = Depends(UserPermissionChecker('imports_edit'))) -> str: if str(import_details.project_id) == str(permissions.permissions.project_id): logger.debug(import_details) - key = await upsert_import(import_model=import_details, engine=db_engine) + key = await upsert_import(import_model=import_details, engine=db_engine, use_commit=True) return str(key) raise InsufficientPermissions('You do not have permission to edit this data import.') @@ -75,7 +75,7 @@ async def delete_import_details(import_id: str, # First, make sure the user trying to delete this import is actually authorised to delete this specific import if import_details is not None and str(import_details.project_id) == str(permissions.permissions.project_id): - await delete_import(import_id=import_id, engine=db_engine) + await delete_import(import_id=import_id, engine=db_engine, use_commit=True) return str(import_id) raise InsufficientPermissions('You do not have permission to delete this data import.') diff --git a/server/api/routes/mailing.py b/server/api/routes/mailing.py index 3255224..db33ed6 100644 --- a/server/api/routes/mailing.py +++ b/server/api/routes/mailing.py @@ -37,19 +37,20 @@ async def reset_password(username: str, await auth_helper.clear_tokens_by_user(username=username) # Create new token token = await auth_helper.refresh_or_create_token(username=username, - token_lifetime_minutes=3 * 60) + token_lifetime_minutes=24 * 60) try: background_tasks.add_task( send_message, sender=None, recipients=[user.email], + bcc=[], subject='[NACSOS] Reset password', message=f'Dear {user.full_name},\n' f'You are receiving this message because you or someone else ' f'tried to reset your password.\n' f'We closed all your active sessions, so you will have to log in again.\n' f'\n' - f'You can use the following link within the next 3h to reset your password:\n' + f'You can use the following link within the next 24h to reset your password:\n' f'{settings.SERVER.WEB_URL}/#/password-reset/{token.token_id}\n' f'\n' f'Sincerely,\n' @@ -73,11 +74,12 @@ async def welcome_mail(username: str, if user is not None and user.email is not None: # Create new token token = await auth_helper.refresh_or_create_token(username=username, - token_lifetime_minutes=3 * 60) + token_lifetime_minutes=24 * 60) background_tasks.add_task( send_message, sender=None, recipients=[user.email], + bcc=[], subject='[NACSOS] Welcome to the platform', message=f'Dear {user.full_name},\n' f'I created an account on our scoping platform for you.\n ' @@ -88,7 +90,7 @@ async def welcome_mail(username: str, f'You can change your password after logging in by opening the user menu at ' f'the top right and clicking "edit profile".\n' f'\n' - f'Alternatively, you can use the following link within the next 3h to reset your password:\n' + f'Alternatively, you can use the following link within the next 24h to reset your password:\n' f'{settings.SERVER.WEB_URL}/#/password-reset/{token.token_id}\n' f'\n' f'We are working on expanding the documentation for the platform here:\n' @@ -138,6 +140,7 @@ async def remind_users_assigment(assignment_scope_id: str, send_message, sender=None, recipients=[res['email']], + bcc=[], subject='[NACSOS] Assignments waiting for you', message=f'Dear {res["full_name"]},\n' f'In the project "{info["project_name"]}", in the scope "{info["scope_name"]}", ' @@ -157,3 +160,49 @@ async def remind_users_assigment(assignment_scope_id: str, else: logger.debug(f'Not reminding {res}') return reminded_users + + +@router.post('/news') +async def news_mail(subject: str, + body: str, + background_tasks: BackgroundTasks, + superuser: UserModel = Depends(get_current_active_superuser)) -> list[str]: + reminded_users: list[str] = [] + + session: AsyncSession + async with db_engine.session() as session: + users = (await session.execute(select(User.email, User.full_name, User.username) + .where(User.setting_newsletter is True, # type: ignore[arg-type] + User.is_active is True))).mappings().all() # type: ignore[arg-type] + + for user in users: + try: + logger.debug(f'Trying to remind {user["username"]}') + background_tasks.add_task( + send_message, + sender=None, + recipients=[user['email']], + bcc=[], + subject=f'[NACSOS] -NEWS- | {subject}', + message=f'Dear {user["full_name"]},\n' + f'\n' + f'The following message was sent to you by the platform:\n' + f'\n' + f'------------------------------------------------------\n' + f'{body}\n' + f'------------------------------------------------------\n' + f' / end of message\n' + f'\n' + f'If you do not want to receive any more emails like this, please log in and ' + f'edit your user profile.\n' + f'We created a guide in the documentation: https://apsis.mcc-berlin.net/nacsos-docs/user/issues/\n' + f'There is also a high-level changelog: https://apsis.mcc-berlin.net/nacsos-docs/news/\n' + f'\n' + f'Sincerely,\n' + f'The Platform' + ) + reminded_users.append(user['username']) + except Exception as e: + logger.exception(e) + + return reminded_users diff --git a/server/api/routes/project/__init__.py b/server/api/routes/project/__init__.py index fa9816b..a878017 100644 --- a/server/api/routes/project/__init__.py +++ b/server/api/routes/project/__init__.py @@ -32,7 +32,7 @@ async def get_project(permission=Depends(UserPermissionChecker())) -> ProjectMod async def save_project(project_info: ProjectModel, permission=Depends(UserPermissionChecker('owner'))) -> str: pkey = await upsert_orm(upsert_model=project_info, Schema=Project, primary_key='project_id', - skip_update=['project_id'], db_engine=db_engine) + skip_update=['project_id'], db_engine=db_engine, use_commit=True) return str(pkey) diff --git a/server/api/routes/search.py b/server/api/routes/search.py index 8be3660..0b49db0 100644 --- a/server/api/routes/search.py +++ b/server/api/routes/search.py @@ -3,9 +3,9 @@ from pydantic import BaseModel from fastapi import APIRouter, Depends import sqlalchemy.sql.functions as func from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.asyncio import AsyncSession # noqa: F401 -from nacsos_data.db.engine import ensure_session +from nacsos_data.db.engine import ensure_session, DBSession from nacsos_data.db.schemas import Project, ItemType from nacsos_data.util.nql import NQLQuery, NQLFilter from nacsos_data.util.academic.readers.openalex import query_async, SearchResult @@ -90,7 +90,7 @@ class QueryResult(BaseModel): @ensure_session -async def _get_query(session: AsyncSession, query: NQLFilter, project_id: str) -> NQLQuery: +async def _get_query(session: DBSession, query: NQLFilter, project_id: str) -> NQLQuery: project_type: ItemType | None = ( await session.scalar(select(Project.type).where(Project.project_id == project_id))) diff --git a/server/pipelines/actor.py b/server/pipelines/actor.py index 9e31441..1d4a569 100644 --- a/server/pipelines/actor.py +++ b/server/pipelines/actor.py @@ -79,7 +79,7 @@ class NacsosActor(Actor[P, R]): params=params, fingerprint=fingerprint, comment=comment, message_id=message.message_id, rec_expunge=self.rec_expunge, status=TaskStatus.PENDING) session.add(task) - session.flush() + session.commit() self.logger.info('Wrote task info to database.') return message diff --git a/server/util/email.py b/server/util/email.py index 11eeebd..c422c40 100644 --- a/server/util/email.py +++ b/server/util/email.py @@ -39,6 +39,7 @@ class EmailNotSentError(Exception): def construct_email(recipients: list[str], + bcc: list[str], subject: str, message: str, sender: str | None = None) -> EmailMessage: @@ -50,21 +51,24 @@ def construct_email(recipients: list[str], email['Subject'] = subject email['From'] = sender # type: ignore[assignment] email['To'] = ', '.join(recipients) + email['Bcc'] = ', '.join(bcc) return email async def send_message(recipients: list[str], + bcc: list[str], subject: str, message: str, sender: str | None = None) -> bool: - email = construct_email(sender=sender, recipients=recipients, subject=subject, message=message) + email = construct_email(sender=sender, recipients=recipients, bcc=bcc, subject=subject, message=message) return await send_email(email) async def send_email(email: EmailMessage) -> bool: if not settings.EMAIL.ENABLED: raise EmailNotSentError(f'Mailing system inactive, ' - f'email with subject "{email["Subject"]}" not sent to {email["To"]}') + f'email with subject "{email["Subject"]}" ' + f'not sent to {email["To"]} (Bcc: {email["Bcc"]})') if email['From'] is None: del email['From'] @@ -89,18 +93,20 @@ async def send_email(email: EmailMessage) -> bool: except (SMTPRecipientsRefused, SMTPResponseException, ValueError, SMTPException, SMTPTimeoutError, SMTPAuthenticationError, SMTPNotSupported, SMTPConnectTimeoutError, SMTPConnectError, SMTPConnectResponseError, SMTPServerDisconnected, SMTPHeloError, SMTPSenderRefused) as e: - logger.warning(f'Failed sending email to {email["To"]} with subject "{email["Subject"]}"') + logger.warning(f'Failed sending email to {email["To"]} (Bcc: {email["Bcc"]}) with subject "{email["Subject"]}"') logger.error(e) await client.quit() - raise EmailNotSentError(f'Email with subject "{email["Subject"]}" not sent to {email["To"]} because of "{e}"') + raise EmailNotSentError(f'Email with subject "{email["Subject"]}" ' + f'not sent to {email["To"]} (Bcc: {email["Bcc"]}) because of "{e}"') def send_message_sync(recipients: list[str], + bcc: list[str], subject: str, message: str, sender: str | None = None) -> bool: - email = construct_email(sender=sender, recipients=recipients, subject=subject, message=message) + email = construct_email(sender=sender, recipients=recipients, bcc=bcc, subject=subject, message=message) return send_email_sync(email) @@ -130,15 +136,18 @@ def send_email_sync(email: EmailMessage) -> bool: smtp.login(user=user, password=password) smtp.connect() - logger.info(f'Trying to send email to {email["To"]} with subject "{email["Subject"]}"') + logger.info(f'Trying to send email to {email["To"]} ' + f'(Bcc: {email["Bcc"]}) with subject "{email["Subject"]}"') status = smtp.send_message(email) logger.debug(status) - logger.info(f'Successfully sent email to {email["To"]} with subject "{email["Subject"]}"') + logger.info(f'Successfully sent email to {email["To"]} ' + f'(Bcc: {email["Bcc"]}) with subject "{email["Subject"]}"') return True except (SMTPHeloErrorOrig, SMTPRecipientsRefusedOrig, SMTPSenderRefusedOrig, SMTPDataError, SMTPNotSupportedError, SMTPExceptionOrig) as e: - logger.warning(f'Failed sending email to {email["To"]} with subject "{email["Subject"]}"') + logger.warning(f'Failed sending email to {email["To"]} (Bcc: {email["Bcc"]}) with subject "{email["Subject"]}"') logger.error(e) - raise EmailNotSentError(f'Email with subject "{email["Subject"]}" not sent to {email["To"]} because of "{e}"') + raise EmailNotSentError(f'Email with subject "{email["Subject"]}" ' + f'not sent to {email["To"]} (Bcc: {email["Bcc"]}) because of "{e}"') -- GitLab