diff --git a/chafan_core/app/api/api_v1/api.py b/chafan_core/app/api/api_v1/api.py index 99513ea..0f464c5 100644 --- a/chafan_core/app/api/api_v1/api.py +++ b/chafan_core/app/api/api_v1/api.py @@ -2,6 +2,7 @@ from chafan_core.app.api.api_v1.endpoints import ( activities, + admin_tools, answer_suggest_edits, answers, applications, @@ -28,6 +29,7 @@ questions, reports, rewards, + rss, search, sitemaps, sites, @@ -38,8 +40,6 @@ users, webhooks, ws, - rss, - admin_tools, ) api_router = APIRouter() @@ -99,7 +99,9 @@ coin_payments.router, prefix="/coin-payments", tags=["coin_payments"] ) api_router.include_router(audit_logs.router, prefix="/audit-logs", tags=["audit_logs"]) -api_router.include_router(admin_tools.router, prefix="/admin_tools", tags=["admin_tools"]) +api_router.include_router( + admin_tools.router, prefix="/admin_tools", tags=["admin_tools"] +) api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"]) api_router.include_router(feedbacks.router, prefix="/feedbacks", tags=["feedbacks"]) api_router.include_router(reports.router, prefix="/reports", tags=["reports"]) diff --git a/chafan_core/app/api/api_v1/endpoints/activities.py b/chafan_core/app/api/api_v1/endpoints/activities.py index 6485912..c712bf3 100644 --- a/chafan_core/app/api/api_v1/endpoints/activities.py +++ b/chafan_core/app/api/api_v1/endpoints/activities.py @@ -1,3 +1,4 @@ +import logging from typing import Any, Optional, Union from fastapi import APIRouter, Depends, Request @@ -12,8 +13,6 @@ from chafan_core.app.schemas.answer import AnswerPreview, AnswerPreviewForVisitor from chafan_core.utils.base import unwrap - -import logging logger = logging.getLogger(__name__) router = APIRouter() @@ -46,10 +45,13 @@ async def get_feed( Get activity feed. """ current_user_id: int = unwrap(cached_layer.principal_id) - logger.info(f"User {current_user_id} GET activity skip={before_activity_id} limit={limit}, random={random}, full={full_answers}") + logger.info( + f"User {current_user_id} GET activity skip={before_activity_id} limit={limit}, random={random}, full={full_answers}" + ) activities = await cached_layer.get_user_activity( - current_user_id, before_activity_id, limit, random, subject_user_uuid) + current_user_id, before_activity_id, limit, random, subject_user_uuid + ) data = schemas.FeedSequence(activities=activities, random=random) return _update_feed_seq(cached_layer, data, full_answers=full_answers) diff --git a/chafan_core/app/api/api_v1/endpoints/admin_tools.py b/chafan_core/app/api/api_v1/endpoints/admin_tools.py index 889c380..94a8b95 100644 --- a/chafan_core/app/api/api_v1/endpoints/admin_tools.py +++ b/chafan_core/app/api/api_v1/endpoints/admin_tools.py @@ -1,14 +1,14 @@ +import logging from fastapi import APIRouter, Depends, Response -from chafan_core.app.config import settings from chafan_core.app.api import deps from chafan_core.app.cached_layer import CachedLayer +from chafan_core.app.config import settings from chafan_core.app.feed import get_site_activities -from chafan_core.utils.base import HTTPException_ from chafan_core.app.responders.rss import build_rss +from chafan_core.utils.base import HTTPException_ -import logging logger = logging.getLogger(__name__) @@ -17,9 +17,10 @@ @router.get("/full_site_activity/{passcode}/rss.xml") async def get_site_activity( - *, response: Response, - cached_layer: CachedLayer = Depends(deps.get_cached_layer), - passcode: str + *, + response: Response, + cached_layer: CachedLayer = Depends(deps.get_cached_layer), + passcode: str ) -> str: """ Get full cha.fan activity. @@ -28,8 +29,8 @@ async def get_site_activity( code = settings.DEBUG_ADMIN_TOOL_FULL_SITE_PASSCODE if code is None or code == "" or code != passcode: raise HTTPException_(status_code=405, detail="Not allowed ") - activities = await get_site_activities(cached_layer, None, settings.LIMIT_RSS_ADMIN_TOOL_FULL_SITE_ITEMS, True) + activities = await get_site_activities( + cached_layer, None, settings.LIMIT_RSS_ADMIN_TOOL_FULL_SITE_ITEMS, True + ) rss_str = build_rss(activities, site=None) return Response(content=rss_str, media_type="application/rss+xml") - - diff --git a/chafan_core/app/api/api_v1/endpoints/answers.py b/chafan_core/app/api/api_v1/endpoints/answers.py index e175ab7..1138f46 100644 --- a/chafan_core/app/api/api_v1/endpoints/answers.py +++ b/chafan_core/app/api/api_v1/endpoints/answers.py @@ -1,3 +1,4 @@ +import logging from typing import Any, List, Union from fastapi import APIRouter, Depends, Query, Request, Response @@ -17,11 +18,10 @@ from chafan_core.app.schemas.answer import AnswerModUpdate from chafan_core.app.schemas.event import EventInternal, UpvoteAnswerInternal from chafan_core.app.schemas.richtext import RichText +from chafan_core.app.task import postprocess_new_answer from chafan_core.utils.base import HTTPException_, filter_not_none, get_utc_now, unwrap from chafan_core.utils.constants import MAX_ARCHIVE_PAGINATION_LIMIT -from chafan_core.app.task import postprocess_new_answer -import logging logger = logging.getLogger(__name__) router = APIRouter() @@ -257,9 +257,9 @@ def _update_answer( if answer_in.updated_content: del answer_in_dict["updated_content"] answer_in_dict["body"] = answer_in.updated_content.source - answer_in_dict[ - "body_prerendered_text" - ] = answer_in.updated_content.rendered_text + answer_in_dict["body_prerendered_text"] = ( + answer_in.updated_content.rendered_text + ) answer_in_dict["editor"] = answer_in.updated_content.editor answer_in_dict["body_draft"] = None diff --git a/chafan_core/app/api/api_v1/endpoints/article_columns.py b/chafan_core/app/api/api_v1/endpoints/article_columns.py index 9600a36..822e739 100644 --- a/chafan_core/app/api/api_v1/endpoints/article_columns.py +++ b/chafan_core/app/api/api_v1/endpoints/article_columns.py @@ -1,11 +1,11 @@ from typing import Any, List, Optional from fastapi import APIRouter, Depends -from chafan_core.app.config import settings from chafan_core.app import crud, schemas from chafan_core.app.api import deps from chafan_core.app.cached_layer import CachedLayer +from chafan_core.app.config import settings from chafan_core.utils.base import HTTPException_, filter_not_none router = APIRouter() @@ -42,7 +42,7 @@ async def get_article_column_articles( ) articles = article_column.articles if not current_user_id: - articles = articles[:settings.VISITORS_READ_ARTICLE_LIMIT] + articles = articles[: settings.VISITORS_READ_ARTICLE_LIMIT] return filter_not_none( [cached_layer.materializer.preview_of_article(a) for a in articles] ) diff --git a/chafan_core/app/api/api_v1/endpoints/articles.py b/chafan_core/app/api/api_v1/endpoints/articles.py index 5ec65b6..6f8ef86 100644 --- a/chafan_core/app/api/api_v1/endpoints/articles.py +++ b/chafan_core/app/api/api_v1/endpoints/articles.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import Any, List, Optional, Union from fastapi import APIRouter, Depends, Request @@ -19,7 +20,6 @@ from chafan_core.utils.base import ContentVisibility, HTTPException_ from chafan_core.utils.constants import MAX_ARCHIVE_PAGINATION_LIMIT -import logging logger = logging.getLogger(__name__) @@ -37,7 +37,10 @@ async def get_article( article = cached_layer.get_article_by_uuid(uuid, current_user_id) if article is None: cached_layer.create_audit( - api=f"get_article {uuid} retrieved None", request=request, user_id=current_user_id) + api=f"get_article {uuid} retrieved None", + request=request, + user_id=current_user_id, + ) raise HTTPException_( status_code=400, detail="The article doesn't exists in the system.", diff --git a/chafan_core/app/api/api_v1/endpoints/channels.py b/chafan_core/app/api/api_v1/endpoints/channels.py index 4131533..eb3d428 100644 --- a/chafan_core/app/api/api_v1/endpoints/channels.py +++ b/chafan_core/app/api/api_v1/endpoints/channels.py @@ -1,3 +1,4 @@ +import logging from typing import Any, List from fastapi import APIRouter, Depends @@ -8,7 +9,6 @@ from chafan_core.app.materialize import check_user_in_channel from chafan_core.utils.base import HTTPException_ -import logging logger = logging.getLogger(__name__) diff --git a/chafan_core/app/api/api_v1/endpoints/comments.py b/chafan_core/app/api/api_v1/endpoints/comments.py index bb9320a..c63ab16 100644 --- a/chafan_core/app/api/api_v1/endpoints/comments.py +++ b/chafan_core/app/api/api_v1/endpoints/comments.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import Any, Optional from fastapi import APIRouter, Depends @@ -12,7 +13,6 @@ from chafan_core.app.task import postprocess_comment_update, postprocess_new_comment from chafan_core.utils.base import HTTPException_ -import logging logger = logging.getLogger(__name__) router = APIRouter() diff --git a/chafan_core/app/api/api_v1/endpoints/invitation_links.py b/chafan_core/app/api/api_v1/endpoints/invitation_links.py index 1f8b705..a4b8fc5 100644 --- a/chafan_core/app/api/api_v1/endpoints/invitation_links.py +++ b/chafan_core/app/api/api_v1/endpoints/invitation_links.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends -from chafan_core.app import crud, schemas, models +from chafan_core.app import crud, models, schemas from chafan_core.app.api import deps from chafan_core.app.cached_layer import CachedLayer from chafan_core.app.common import OperationType @@ -35,14 +35,15 @@ async def create_invitation_link( ) invited_to_site_id = invited_to_site.id invitation_link = await crud.invitation_link.create_invitation( - db, invited_to_site_id=invited_to_site_id, inviter=current_user - ) - crud.audit_log.create_with_user( - db, ipaddr="0.0.0.0", user_id=current_user.id, api=f"Created invitation link {invitation_link.uuid}" + db, invited_to_site_id=invited_to_site_id, inviter=current_user ) - return cached_layer.materializer.invitation_link_schema_from_orm( - invitation_link + crud.audit_log.create_with_user( + db, + ipaddr="0.0.0.0", + user_id=current_user.id, + api=f"Created invitation link {invitation_link.uuid}", ) + return cached_layer.materializer.invitation_link_schema_from_orm(invitation_link) @router.get("/daily", response_model=schemas.InvitationLink) diff --git a/chafan_core/app/api/api_v1/endpoints/login.py b/chafan_core/app/api/api_v1/endpoints/login.py index 828fbf4..45e4c48 100644 --- a/chafan_core/app/api/api_v1/endpoints/login.py +++ b/chafan_core/app/api/api_v1/endpoints/login.py @@ -1,9 +1,9 @@ import datetime import json +import logging from typing import Any, List, Literal, Mapping, Optional from urllib.parse import parse_qs, urlparse -import logging logger = logging.getLogger(__name__) import requests @@ -19,20 +19,7 @@ from chafan_core.app import crud, models, schemas, security from chafan_core.app.api import deps from chafan_core.app.cached_layer import CachedLayer -from chafan_core.app.common import ( - check_email, - client_ip, - get_redis_cli, - is_dev, -) -from chafan_core.app.security import ( - check_token_validity_impl, - generate_password_reset_token, - verify_password_reset_token, - create_digit_verification_code, - register_digit_verification_code, - check_digit_verification_code, -) +from chafan_core.app.common import check_email, client_ip, get_redis_cli, is_dev from chafan_core.app.config import settings from chafan_core.app.email.utils import ( send_reset_password_email, @@ -57,8 +44,15 @@ LoginWithVerificationCode, VerificationCodeRequest, ) - -from chafan_core.app.security import get_password_hash +from chafan_core.app.security import ( + check_digit_verification_code, + check_token_validity_impl, + create_digit_verification_code, + generate_password_reset_token, + get_password_hash, + register_digit_verification_code, + verify_password_reset_token, +) from chafan_core.app.task_utils import execute_with_db from chafan_core.db.session import ReadSessionLocal from chafan_core.utils.base import HTTPException_ @@ -70,6 +64,7 @@ router = APIRouter() + # The user's authentication MUST be passed when calling this function def _login_user(db: Session, *, request: Request, user: models.User) -> schemas.Token: if not crud.user.is_active(user): @@ -213,7 +208,7 @@ async def recover_password( """ Password Recovery """ - user = crud.user.get_by_email(db, email=email) #Optional[User] + user = crud.user.get_by_email(db, email=email) # Optional[User] if not user: raise HTTPException_( @@ -221,7 +216,10 @@ async def recover_password( detail="The user with this email does not exist in the system.", ) crud.audit_log.create_with_user( - db, ipaddr=client_ip(request), user_id=user.id, api=f"Password reset email sent to {email}" + db, + ipaddr=client_ip(request), + user_id=user.id, + api=f"Password reset email sent to {email}", ) password_reset_token = generate_password_reset_token(email=email) await send_reset_password_email(email=user.email, token=password_reset_token) @@ -231,8 +229,11 @@ async def recover_password( @router.post("/send-verification-code", response_model=schemas.GenericResponse) @limiter.limit("1/minute") async def send_verification_code( - response: Response, request: Request, *, request_in: VerificationCodeRequest, - db: Session = Depends(deps.get_db) + response: Response, + request: Request, + *, + request_in: VerificationCodeRequest, + db: Session = Depends(deps.get_db), ) -> Any: logger.info(str(request_in)) @@ -244,13 +245,16 @@ async def send_verification_code( ) # TODO audit log should support user_id is NULL. 2025-Jul-06 crud.audit_log.create_with_user( - db, ipaddr=client_ip(request), user_id=1, api="send_verification_code to email " + request_in.email + db, + ipaddr=client_ip(request), + user_id=1, + api="send_verification_code to email " + request_in.email, ) code = create_digit_verification_code(6) await send_verification_code_email(email=request_in.email, code=code) await register_digit_verification_code(request_in.email, code) # We may switch to trio + hypercorn in future 2025-Jul-06 - #async with trio.open_nursery() as nursery: + # async with trio.open_nursery() as nursery: # nursery.start_soon(send_verification_code_email,email=request_in.email, code=code) # nursery.start_soon(register_digit_verification_code, request_in.email, code) return schemas.GenericResponse() @@ -266,7 +270,7 @@ async def create_user_open( code: str = Body(...), invitation_link_uuid: str = Body(...), ) -> Any: - if (not settings.USERS_OPEN_REGISTRATION): + if not settings.USERS_OPEN_REGISTRATION: raise HTTPException_( status_code=status.HTTP_403_FORBIDDEN, detail="Open user registration is forbidden on this server", @@ -280,7 +284,9 @@ async def create_user_open( crud.audit_log.create_with_user( db, ipaddr="0.0.0.0", user_id=1, api="Open new account email " + email ) - invitation_link_valid = await cached_layer.try_consume_invitation_link_by_uuid(invitation_link_uuid) + invitation_link_valid = await cached_layer.try_consume_invitation_link_by_uuid( + invitation_link_uuid + ) if not invitation_link_valid: raise HTTPException_( status_code=status.HTTP_401_UNAUTHORIZED, @@ -304,23 +310,22 @@ async def create_user_open( ver_code = await check_digit_verification_code(email, code) if not ver_code: raise HTTPException_( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="The verification code is not present in the system.", - ) + status_code=status.HTTP_401_UNAUTHORIZED, + detail="The verification code is not present in the system.", + ) user_in = schemas.UserCreate(password=password, handle=handle, email=email) user = await crud.user.create(db, obj_in=user_in) # TODO auto add site - if False and invitation_link.invited_to_site is not None: + if False and invitation_link.invited_to_site is not None: # type: ignore[name-defined] # noqa: F821 existing_profile = crud.profile.get_by_user_and_site( - db, owner_id=user.id, site_id=invitation_link.invited_to_site.id + db, owner_id=user.id, site_id=invitation_link.invited_to_site.id # type: ignore[name-defined] # noqa: F821 ) if not existing_profile: cached_layer.create_site_profile( - owner=user, site_uuid=invitation_link.invited_to_site.uuid + owner=user, site_uuid=invitation_link.invited_to_site.uuid # type: ignore[name-defined] # noqa: F821 ) - # TODO bonus for invite new user, new user's initial coins return user_schema_from_orm(user) diff --git a/chafan_core/app/api/api_v1/endpoints/people.py b/chafan_core/app/api/api_v1/endpoints/people.py index 768195c..b34316a 100644 --- a/chafan_core/app/api/api_v1/endpoints/people.py +++ b/chafan_core/app/api/api_v1/endpoints/people.py @@ -115,10 +115,10 @@ def get_user_public( detail="The user doesn't exists in the system.", ) # TODO turn it off 2025-07-23 - view_times = 5 # view_counters.get_views(user.uuid, "profile") + view_times = 5 # view_counters.get_views(user.uuid, "profile") if current_user_id is None: return _get_user_public_visitor(cached_layer, user, view_times) - #view_counters.add_view(user.uuid, "profile", current_user_id) + # view_counters.add_view(user.uuid, "profile", current_user_id) db.commit() about_content = None if user.about is not None: @@ -227,7 +227,7 @@ def get_user_submissions( ) return filter_not_none( [ - #cached_layer.materializer.submission_schema_from_orm(submission) + # cached_layer.materializer.submission_schema_from_orm(submission) cached_layer.submission_schema_from_orm(submission) for submission in user.submissions ] @@ -238,7 +238,7 @@ def get_user_submissions( async def get_user_articles( *, cached_layer: CachedLayer = Depends(deps.get_cached_layer), - #cached_layer: CachedLayer = Depends(deps.get_cached_layer_logged_in), + # cached_layer: CachedLayer = Depends(deps.get_cached_layer_logged_in), uuid: str, skip: int = Query(default=0, ge=0), limit: int = Query( @@ -257,7 +257,7 @@ async def get_user_articles( # TODO "owner" is repeated in this response 2025-Mar-23 return filter_not_none( [ - # TODO we have limit, but we still generate all articles. Need to rewrite with python generator 2025-Mar-23 + # TODO we have limit, but we still generate all articles. Need to rewrite with python generator 2025-Mar-23 cached_layer.materializer.preview_of_article(article) for article in user.articles ] diff --git a/chafan_core/app/api/api_v1/endpoints/questions.py b/chafan_core/app/api/api_v1/endpoints/questions.py index e76b319..3901529 100644 --- a/chafan_core/app/api/api_v1/endpoints/questions.py +++ b/chafan_core/app/api/api_v1/endpoints/questions.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import Any, List, Optional, Union from fastapi import APIRouter, Depends, Request, Response @@ -20,7 +21,6 @@ from chafan_core.app.task import postprocess_new_question, postprocess_updated_question from chafan_core.utils.base import HTTPException_, filter_not_none -import logging logger = logging.getLogger(__name__) router = APIRouter() @@ -61,12 +61,12 @@ def _get_question_data( ) -> Union[schemas.Question, schemas.QuestionForVisitor]: # TODO removed the check for principle id 2025-07-23 question_data = cached_layer.question_schema_from_orm(question) -# if cached_layer.principal_id is None: -# question_data = cached_layer.materializer.question_for_visitor_schema_from_orm( -# question -# ) -# else: -# question_data = cached_layer.materializer.question_schema_from_orm(question) + # if cached_layer.principal_id is None: + # question_data = cached_layer.materializer.question_for_visitor_schema_from_orm( + # question + # ) + # else: + # question_data = cached_layer.materializer.question_schema_from_orm(question) if question_data is None: raise HTTPException_( status_code=400, @@ -92,8 +92,8 @@ def get_question( question = cached_layer.get_question_by_uuid(uuid) if question.is_hidden: raise HTTPException_( - status_code=403, - detail="Not allowed to access this quesion", + status_code=403, + detail="Not allowed to access this quesion", ) return _get_question_data(cached_layer, question) @@ -108,8 +108,8 @@ async def bump_views_counter( question = cached_layer.get_question_model_http(uuid) if question is None: raise HTTPException_( - status_code=404, - detail="No such question", + status_code=404, + detail="No such question", ) assert isinstance(question, models.Question) await view_counters.add_view_async(cached_layer, "question", question.id) @@ -504,11 +504,11 @@ async def get_question_page( question = cached_layer.get_question_by_uuid(uuid, current_user_id) if question is None: cached_layer.create_audit( - api=f"get_question_page {uuid} retrieved None", request=request, user_id=current_user_id) - raise HTTPException_( - status_code=404, - detail="No such question" - ) + api=f"get_question_page {uuid} retrieved None", + request=request, + user_id=current_user_id, + ) + raise HTTPException_(status_code=404, detail="No such question") question_data = _get_question_data(cached_layer, question) flags = schemas.QuestionPageFlags() if cached_layer.principal_id: diff --git a/chafan_core/app/api/api_v1/endpoints/rss.py b/chafan_core/app/api/api_v1/endpoints/rss.py index cda6d9e..7ff79dd 100644 --- a/chafan_core/app/api/api_v1/endpoints/rss.py +++ b/chafan_core/app/api/api_v1/endpoints/rss.py @@ -1,23 +1,25 @@ -from fastapi import APIRouter, Depends, Response +import logging +from fastapi import APIRouter, Depends, Response -from chafan_core.app.config import settings from chafan_core.app.api import deps from chafan_core.app.cached_layer import CachedLayer +from chafan_core.app.config import settings from chafan_core.app.feed import get_site_activities from chafan_core.app.responders.rss import build_rss from chafan_core.utils.base import HTTPException_ - -import logging logger = logging.getLogger(__name__) router = APIRouter() + @router.get("/site/{subdomain}/rss.xml") async def get_site_activity( - *, response: Response, - cached_layer: CachedLayer = Depends(deps.get_cached_layer), subdomain: str + *, + response: Response, + cached_layer: CachedLayer = Depends(deps.get_cached_layer), + subdomain: str ) -> str: """ Get a site's activity. @@ -28,9 +30,9 @@ async def get_site_activity( raise HTTPException_(status_code=404, detail="No such site " + subdomain) if not site.public_readable: raise HTTPException_(status_code=405, detail="Not allowed " + subdomain) - activities = await get_site_activities(cached_layer, site, settings.LIMIT_RSS_RESPONSE_ITEMS) + activities = await get_site_activities( + cached_layer, site, settings.LIMIT_RSS_RESPONSE_ITEMS + ) logger.info("api get: " + str(activities)) rss_str = build_rss(activities, site) return Response(content=rss_str, media_type="application/rss+xml") - - diff --git a/chafan_core/app/api/api_v1/endpoints/search.py b/chafan_core/app/api/api_v1/endpoints/search.py index 33d0b87..edc7dac 100644 --- a/chafan_core/app/api/api_v1/endpoints/search.py +++ b/chafan_core/app/api/api_v1/endpoints/search.py @@ -1,16 +1,15 @@ +import logging from typing import Any, List - from fastapi import APIRouter, Depends, Request, Response from chafan_core.app import crud, models, schemas from chafan_core.app.api import deps from chafan_core.app.cached_layer import CachedLayer -from chafan_core.app.materialize import preview_of_question_as_search_hit from chafan_core.app.limiter import limiter +from chafan_core.app.materialize import preview_of_question_as_search_hit from chafan_core.utils.base import filter_not_none -import logging logger = logging.getLogger(__name__) router = APIRouter() @@ -77,7 +76,7 @@ async def search_questions( if q == "": return [] questions = crud.question.search(cached_layer.get_db(), q=q) -# TODO no search hit limit + # TODO no search hit limit return filter_not_none( [await preview_of_question_as_search_hit(q) for q in questions] ) diff --git a/chafan_core/app/api/api_v1/endpoints/sites.py b/chafan_core/app/api/api_v1/endpoints/sites.py index af0b7f4..37e7a6d 100644 --- a/chafan_core/app/api/api_v1/endpoints/sites.py +++ b/chafan_core/app/api/api_v1/endpoints/sites.py @@ -1,13 +1,13 @@ import datetime +import logging from typing import Any, Dict, List, Optional, Union from fastapi import APIRouter, Depends from fastapi.param_functions import Query from sqlalchemy.orm import Session -import chafan_core.app.responders as responders +import chafan_core.app.responders as responders -import logging logger = logging.getLogger(__name__) @@ -27,7 +27,6 @@ EventInternal, ) from chafan_core.app.user_permission import user_in_site - from chafan_core.utils.base import EntityType, HTTPException_, filter_not_none, unwrap from chafan_core.utils.constants import MAX_SITE_QUESTIONS_PAGINATION_LIMIT @@ -104,9 +103,9 @@ def create_site( category_topic_id: Optional[int] = None if site_in.category_topic_uuid: raise HTTPException_( - status_code=400, - detail="Attach a category topic id when creating a site is disabled.", - ) + status_code=400, + detail="Attach a category topic id when creating a site is disabled.", + ) utc_now = datetime.datetime.now(tz=datetime.timezone.utc) super_user = crud.user.get_superuser(db) new_site = cached_layer.create_site( @@ -194,18 +193,19 @@ def config_site( from chafan_core.app.common import OperationType + @router.get("/{subdomain}", response_model=schemas.Site) async def get_site_info( *, cached_layer: CachedLayer = Depends(deps.get_cached_layer), current_user_id: Optional[int] = Depends(deps.try_get_current_user_id), - subdomain: str + subdomain: str, ) -> Any: """ Get a site's basic info. """ logger.info(f"user {current_user_id} requesting site {subdomain}") - #site_data = cached_layer.get_site_info(subdomain=subdomain) + # site_data = cached_layer.get_site_info(subdomain=subdomain) db = cached_layer.get_db() site = crud.site.get_by_subdomain(db, subdomain=subdomain) @@ -216,7 +216,7 @@ async def get_site_info( ) if not user_in_site(db, site, current_user_id, OperationType.ReadSite): logger.info("user has no permission") - #TODO add audit + # TODO add audit raise HTTPException_( status_code=404, detail="The site with this id does not exist in the system", @@ -255,9 +255,7 @@ def get_site_questions( ) max_questions = settings.API_LIMIT_SITES_GET_QUESTIONS_LIMIT limit = min(limit, max_questions) - questions = crud.site.get_multi_questions( - db, db_obj=site, skip=skip, limit=limit - ) + questions = crud.site.get_multi_questions(db, db_obj=site, skip=skip, limit=limit) return filter_not_none( [cached_layer.materializer.preview_of_question(q) for q in questions] ) @@ -448,7 +446,4 @@ def get_related( crud.site.get(cached_layer.get_db(), site_id) ) - return [ - cached_layer.site_schema_from_orm(s) - for s in related_sites.values() - ] + return [cached_layer.site_schema_from_orm(s) for s in related_sites.values()] diff --git a/chafan_core/app/api/api_v1/endpoints/submissions.py b/chafan_core/app/api/api_v1/endpoints/submissions.py index 2683916..f4bbea6 100644 --- a/chafan_core/app/api/api_v1/endpoints/submissions.py +++ b/chafan_core/app/api/api_v1/endpoints/submissions.py @@ -1,11 +1,11 @@ import datetime +import logging from typing import Any, List, Optional, Union from fastapi import APIRouter, Depends, Request from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session -import logging logger = logging.getLogger(__name__) diff --git a/chafan_core/app/api/api_v1/endpoints/ws.py b/chafan_core/app/api/api_v1/endpoints/ws.py index 689c828..bb5990e 100644 --- a/chafan_core/app/api/api_v1/endpoints/ws.py +++ b/chafan_core/app/api/api_v1/endpoints/ws.py @@ -1,4 +1,5 @@ import asyncio +import logging import secrets from datetime import timedelta from typing import Any @@ -7,13 +8,11 @@ from fastapi.websockets import WebSocket, WebSocketDisconnect from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK - from chafan_core.app import schemas, ws_connections from chafan_core.app.api import deps from chafan_core.app.common import get_redis_cli from chafan_core.app.mq import get_ws_queue_for_user -import logging logger = logging.getLogger(__name__) router = APIRouter() @@ -33,7 +32,7 @@ async def get_ws_token( return schemas.WsAuthResponse(token=token) -async def _read_message_queue(redis, queue_name:str): +async def _read_message_queue(redis, queue_name: str): item = redis.lpop(queue_name) return item diff --git a/chafan_core/app/api/health.py b/chafan_core/app/api/health.py index b764936..34fb9c1 100644 --- a/chafan_core/app/api/health.py +++ b/chafan_core/app/api/health.py @@ -11,6 +11,7 @@ def get_health() -> Any: return schemas.HealthResponse() + @router.get("/health", response_model=schemas.HealthResponse) def get_health_test() -> Any: return schemas.HealthResponse() diff --git a/chafan_core/app/cached_layer.py b/chafan_core/app/cached_layer.py index 0e4e55d..c27a86e 100644 --- a/chafan_core/app/cached_layer.py +++ b/chafan_core/app/cached_layer.py @@ -1,36 +1,33 @@ import asyncio import datetime import json +import logging import random from collections import Counter from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TypeVar, Union - +import fastapi import redis import requests import sentry_sdk -import fastapi from fastapi.encoders import jsonable_encoder from pydantic import TypeAdapter from sqlalchemy.orm.session import Session -from chafan_core.app.feed import get_activities_v2, get_random_activities -from chafan_core.app.config import settings +import chafan_core.app.responders as responders from chafan_core.app import crud, models, schemas -from chafan_core.app.common import is_dev -from chafan_core.app.common import client_ip -from chafan_core.app.user_permission import ( - article_read_allowed, - question_read_allowed, - ) +from chafan_core.app.common import client_ip, is_dev +from chafan_core.app.config import settings from chafan_core.app.data_broker import DataBroker +from chafan_core.app.feed import get_activities_v2, get_random_activities + # TODO 2025-07-20 CachedLayer should not dependent on Materializer from chafan_core.app.materialize import Materializer -import chafan_core.app.responders as responders from chafan_core.app.recs.ranking import rank_site_profiles, rank_submissions from chafan_core.app.schemas.answer import AnswerPreview, AnswerPreviewForVisitor from chafan_core.app.schemas.preview import UserPreview from chafan_core.app.schemas.site import SiteCreate +from chafan_core.app.user_permission import article_read_allowed, question_read_allowed from chafan_core.utils.base import ( ContentVisibility, EntityType, @@ -39,7 +36,6 @@ unwrap, ) -import logging logger = logging.getLogger(__name__) MatrixType = Dict[int, List[int]] @@ -79,6 +75,7 @@ BUMP_VIEW_COUNT_QUEUE_CACHE_KEY = "chafan:bump-view-count" + class CachedLayer(object): def __init__(self, broker: DataBroker, principal_id: Optional[int] = None) -> None: self.broker = broker @@ -89,12 +86,10 @@ def __init__(self, broker: DataBroker, principal_id: Optional[int] = None) -> No self._follow_follow_fanout: Optional[WeightedMatrixType] = None self._entity_similarity_matrices: Dict[EntityType, MatrixType] = {} - def bump_view(self, object_type: str, obj_id:int): + def bump_view(self, object_type: str, obj_id: int): obj_str = f"{object_type}:{obj_id}" self.get_redis().rpush(BUMP_VIEW_COUNT_QUEUE_CACHE_KEY, obj_str) - - def unwrapped_principal_id(self) -> int: return unwrap(self.principal_id) @@ -163,45 +158,57 @@ def _get_cached_non_empty( ) return data - def question_schema_from_orm(self, question: models.Question) -> Optional[schemas.Question]: + def question_schema_from_orm( + self, question: models.Question + ) -> Optional[schemas.Question]: logger.info("called cached.layer for question " + str(question)) return responders.question.question_schema_from_orm( - self.broker, self.principal_id, question, self) + self.broker, self.principal_id, question, self + ) - def submission_schema_from_orm(self, submission: models.Submission) : - logger.info("called cached layer for submission to wrap submission " + str(submission.id)) - return responders.submission.submission_schema_from_orm( - self, submission) + def submission_schema_from_orm(self, submission: models.Submission): + logger.info( + "called cached layer for submission to wrap submission " + + str(submission.id) + ) + return responders.submission.submission_schema_from_orm(self, submission) def article_schema_from_orm(self, article: models.Article): logger.info("called cached layer for article") return responders.article.article_schema_from_orm( - self, article, self.principal_id) - + self, article, self.principal_id + ) - def get_article_by_uuid(self, uuid: str, current_user_id:Optional[int]=None) -> models.Article: + def get_article_by_uuid( + self, uuid: str, current_user_id: Optional[int] = None + ) -> models.Article: db = self.get_db() article = crud.article.get_by_uuid(db, uuid=uuid) if not article_read_allowed(db, article, current_user_id): return None return article - def get_article_by_id(self, article_id:int, current_user_id:Optional[int]=None) -> models.Article: + def get_article_by_id( + self, article_id: int, current_user_id: Optional[int] = None + ) -> models.Article: article = crud.article.get(self.get_db(), id=article_id) if not article_read_allowed(self.get_db(), article, current_user_id): return None return article - def get_answer_by_id(self, answer_id:int): + def get_answer_by_id(self, answer_id: int): db = self.get_db() answer = crud.answer.get_by_id(db, uid=answer_id) return answer def answer_schema_from_orm(self, answer): - answer_data = responders.answer.answer_schema_from_orm(self, answer, self.principal_id) + answer_data = responders.answer.answer_schema_from_orm( + self, answer, self.principal_id + ) if answer_data: answer_data.upvotes = self.get_answer_upvotes(answer.uuid) return answer_data + def get_answer( self, uuid: str ) -> Optional[Union[schemas.Answer, schemas.AnswerForVisitor]]: @@ -209,7 +216,9 @@ def get_answer( answer = crud.answer.get_by_uuid(db, uuid=uuid) if answer is None: return None - answer_data = responders.answer.answer_schema_from_orm(self, answer, self.principal_id) + answer_data = responders.answer.answer_schema_from_orm( + self, answer, self.principal_id + ) if answer_data: answer_data.upvotes = self.get_answer_upvotes(uuid) return answer_data @@ -352,7 +361,9 @@ def get_site_submissions_for_user( ) else: # FIXME: compute rank async - logger.error("TODO submission list for visitors are turned off due to `desc` missing") #2025-Jul-30 + logger.error( + "TODO submission list for visitors are turned off due to `desc` missing" + ) # 2025-Jul-30 submissions = [] # submissions = rank_submissions( # filter_not_none( @@ -401,7 +412,8 @@ def get_site_maps(self) -> schemas.site.SiteMaps: sites_without_topics=sites_without_topics, ) redis_cli.set( - SITEMAPS_CACHE_KEY, data.json(), ex=settings.CACHE_SITEMAP_VALID_HOURS) + SITEMAPS_CACHE_KEY, data.json(), ex=settings.CACHE_SITEMAP_VALID_HOURS + ) return data def create_site( @@ -597,22 +609,25 @@ def preview_of_user(self, user: models.User) -> schemas.UserPreview: user_preview.follows = self.get_user_follows(user) return user_preview - def create_audit(self, api:str, request: Optional[fastapi.Request]=None, - user_id:Optional[int]=None, - request_info:dict=dict()): + def create_audit( + self, + api: str, + request: Optional[fastapi.Request] = None, + user_id: Optional[int] = None, + request_info: dict = dict(), + ): ip = "0.0.0.0" if request is not None: ip = client_ip(request) if user_id is None: user_id = 1 crud.audit_log.create_with_user( - self.get_db(), - ipaddr=ip, - user_id=user_id, - api=api, - request_info=request_info - ) - + self.get_db(), + ipaddr=ip, + user_id=user_id, + api=api, + request_info=request_info, + ) def update_notification( self, @@ -657,7 +672,9 @@ def get_question_model_http(self, uuid: str) -> models.Question: ) return question - def get_question_by_uuid(self, uuid: str, current_user_id:Optional[int]=None) -> models.Question: + def get_question_by_uuid( + self, uuid: str, current_user_id: Optional[int] = None + ) -> models.Question: question = crud.question.get_by_uuid(self.get_db(), uuid=uuid) if question is None: return None @@ -767,9 +784,11 @@ def get_daily_invitation_link(self) -> schemas.InvitationLink: db = self.get_db() def f() -> int: - return asyncio.run(crud.invitation_link.create_invitation( - db, invited_to_site_id=None, inviter=crud.user.get_superuser(db) - )).id + return asyncio.run( + crud.invitation_link.create_invitation( + db, invited_to_site_id=None, inviter=crud.user.get_superuser(db) + ) + ).id cached_id = self._get_cached( key=DAILY_INVITATION_LINK_ID_CACHE_KEY, @@ -833,26 +852,27 @@ def incr(timestamp: datetime.datetime, action: str) -> None: return ret async def get_user_activity( - self, - current_user_id: int, - before_activity_id:Optional[int], - limit:int, - random:bool, - subject_user_uuid: Optional[str]): + self, + current_user_id: int, + before_activity_id: Optional[int], + limit: int, + random: bool, + subject_user_uuid: Optional[str], + ): logger.info(f"cached_layer get_user_activity for {current_user_id}") redis = self.get_redis() key = f"chafan:feed-cache:user:{current_user_id}:before_activity_id={before_activity_id}&limit={limit}&subject_user_uuid={subject_user_uuid}" value = redis.get(key) value = None - if value: # TODO redis cache is broken for now 2025-08-04 + if value: # TODO redis cache is broken for now 2025-08-04 return None -# return _update_feed_seq( -# cached_layer, -# schemas.FeedSequence.model_validate_json(value), -# full_answers=full_answers, -# ) + # return _update_feed_seq( + # cached_layer, + # schemas.FeedSequence.model_validate_json(value), + # full_answers=full_answers, + # ) activities = await get_activities_v2( - cached_layer = self, + cached_layer=self, before_activity_id=before_activity_id, limit=limit, receiver_user_id=current_user_id, @@ -864,11 +884,7 @@ async def get_user_activity( if random: tolerate_order = True - if ( - tolerate_order - and insufficient > 0 - and subject_user_uuid is None - ): + if tolerate_order and insufficient > 0 and subject_user_uuid is None: random = True extra_activities = get_random_activities( receiver_user_id=current_user_id, @@ -878,15 +894,13 @@ async def get_user_activity( activities.extend(extra_activities) redis.delete(key) - redis.set( # TODO fixme + redis.set( # TODO fixme key, json.dumps(jsonable_encoder(1)), ex=datetime.timedelta(minutes=1), ) return activities - - def get_user_contributions(self, user: models.User) -> UserContributions: if user.id in self._user_contributions_map: return self._user_contributions_map[user.id] @@ -1000,10 +1014,8 @@ def remove_site_profile(self, *, owner_id: int, site_id: int) -> None: ) self.get_redis().delete(USER_SITE_PROFILES.format(user_id=owner_id)) - # TODO maybe this is not the best place to put it 2025-Jul-06 - async def try_consume_invitation_link_by_uuid( - self, invitation_uuid:str) -> bool: + async def try_consume_invitation_link_by_uuid(self, invitation_uuid: str) -> bool: logger.info(f"Consumed invitation link uuid=${invitation_uuid}") db = self.get_db() invitation_link = crud.invitation_link.get_by_uuid(db, uuid=invitation_uuid) diff --git a/chafan_core/app/common.py b/chafan_core/app/common.py index d1aa80b..8ec9764 100644 --- a/chafan_core/app/common.py +++ b/chafan_core/app/common.py @@ -67,7 +67,7 @@ def run_dramatiq_task(task: Any, *arg: Any, **kwargs: Any) -> None: # TODO This function should be moved out of common.py, to task.py print("run_dramatiq_task") print(arg) - #task(*arg, **kwargs) + # task(*arg, **kwargs) task.send(*arg, **kwargs) @@ -75,7 +75,6 @@ def from_now(utc: datetime.datetime, locale: str) -> str: return arrow.get(utc).humanize(locale=locale) - def html2plaintext(t: str) -> str: h = HTML2Text() h.ignore_links = True @@ -113,12 +112,18 @@ def html2plaintext(t: str) -> str: "answer_question": ("{{who}}回答了你的问题", "「{{question}}」:「{{answer}}」"), "answer_update": ("{{who}}更新了回答", "「{{question}}」:「{{answer}}」"), "comment_question": ("{{who}}评论了你的问题", "「{{question}}」:「{{comment}}」"), - "comment_submission": ("{{who}}评论了你的分享", "「{{submission}}」:「{{comment}}」"), + "comment_submission": ( + "{{who}}评论了你的分享", + "「{{submission}}」:「{{comment}}」", + ), "reply_comment": ("{{who}}回复了你的评论", "「{{parent_comment}}」:「{{reply}}」"), "invite_answer": ("{{who}}邀请你回答问题", "「{{question}}」"), "invite_join_site": ("{{who}}邀请你加入圈子", "「{{site}}」"), "apply_join_site": ("{{who}}申请加入圈子", "「{{site}}」"), - "comment_answer": ("{{who}}评论了你对问题的回答", "「{{question}}」:「{{comment}}」"), + "comment_answer": ( + "{{who}}评论了你对问题的回答", + "「{{question}}」:「{{comment}}」", + ), "comment_article": ("{{who}}评论了你的文章", "「{{article}}」:「{{comment}}」"), "upvote_answer": ("{{who}}赞了你对问题的回答", "「{{question}}」:「{{answer}}」"), "follow_user": ("{{who}}关注了你", ""), diff --git a/chafan_core/app/config.py b/chafan_core/app/config.py index 81cfe02..3f2b8be 100644 --- a/chafan_core/app/config.py +++ b/chafan_core/app/config.py @@ -1,10 +1,10 @@ from typing import Literal, Optional import sentry_sdk -from sentry_sdk.integrations.dramatiq import DramatiqIntegration from pydantic import AnyHttpUrl from pydantic.types import SecretStr from pydantic_settings import BaseSettings +from sentry_sdk.integrations.dramatiq import DramatiqIntegration from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration @@ -26,7 +26,6 @@ class Settings(BaseSettings): ENABLE_CAPTCHA: bool = False - EMAILS_ENABLED: bool = False EMAIL_SMTP_HOST: Optional[str] = None EMAIL_SMTP_PORT: Optional[int] = None @@ -34,7 +33,6 @@ class Settings(BaseSettings): EMAIL_SMTP_LOGIN_PASSWORD: Optional[str] = None EMAIL_TEMPLATES_DIR: str = "chafan_core/app/email-templates/build" - AWS_ACCESS_KEY_ID: Optional[str] = None AWS_SECRET_ACCESS_KEY: Optional[str] = None AWS_REGION: Optional[str] = None @@ -82,7 +80,7 @@ class Config: case_sensitive = True ### Limit settings - VISITORS_READ_ARTICLE_LIMIT: int = 100 #previous 5 + VISITORS_READ_ARTICLE_LIMIT: int = 100 # previous 5 LIMIT_RSS_RESPONSE_ITEMS: int = 200 LIMIT_RSS_ADMIN_TOOL_FULL_SITE_ITEMS: int = 500 @@ -103,7 +101,6 @@ class Config: CREATE_SITE_FORCE_NEED_APPROVAL: bool = True - setting_keys = set(Settings.schema()["properties"].keys()) settings = Settings() diff --git a/chafan_core/app/crud/crud_invitation.py b/chafan_core/app/crud/crud_invitation.py index 95cec20..a1d5f03 100644 --- a/chafan_core/app/crud/crud_invitation.py +++ b/chafan_core/app/crud/crud_invitation.py @@ -42,4 +42,5 @@ def create_invitation( db.refresh(db_obj) return db_obj + invitation = CRUDComment() diff --git a/chafan_core/app/crud/crud_invitation_link.py b/chafan_core/app/crud/crud_invitation_link.py index 86ae620..432c1de 100644 --- a/chafan_core/app/crud/crud_invitation_link.py +++ b/chafan_core/app/crud/crud_invitation_link.py @@ -1,4 +1,5 @@ import datetime +import logging from typing import Optional from sqlalchemy.orm import Session @@ -11,7 +12,6 @@ InvitationLinkUpdate, ) -import logging logger = logging.getLogger(__name__) @@ -40,5 +40,4 @@ async def create_invitation( return db_obj - invitation_link = CRUDInvitationLink(InvitationLink) diff --git a/chafan_core/app/crud/crud_user.py b/chafan_core/app/crud/crud_user.py index d5cf97b..f55b820 100644 --- a/chafan_core/app/crud/crud_user.py +++ b/chafan_core/app/crud/crud_user.py @@ -1,7 +1,7 @@ import datetime +import logging from typing import Any, Dict, List, Optional, Union -import logging logger = logging.getLogger(__name__) from pydantic.types import SecretStr @@ -63,7 +63,7 @@ def get_by_handle(self, db: Session, *, handle: str) -> Optional[User]: def get_all_active_users(self, db: Session) -> List[User]: return db.query(User).filter_by(is_active=True).all() -# 2025-Dec-11 This function is synchronized for now. We define it as async to facilitate further improvement + # 2025-Dec-11 This function is synchronized for now. We define it as async to facilitate further improvement async def create(self, db: Session, *, obj_in: UserCreate) -> User: if obj_in.handle is None: handle = StrippedNonEmptyBasicStr( diff --git a/chafan_core/app/email/mock_client.py b/chafan_core/app/email/mock_client.py index 311f3ca..03dc7a1 100644 --- a/chafan_core/app/email/mock_client.py +++ b/chafan_core/app/email/mock_client.py @@ -2,26 +2,30 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText + class MockEmailClient(object): smtp = None + def __init__(self): pass - def build_email(self, from_addr, to_addrs, subject, html_body)->EmailMessage: + + def build_email(self, from_addr, to_addrs, subject, html_body) -> EmailMessage: msg = MIMEMultipart("alternative") - msg['Subject'] = subject - msg['From'] = from_addr - msg['To'] = to_addrs # TODO not tested if it supports more emails 2025-06-25 + msg["Subject"] = subject + msg["From"] = from_addr + msg["To"] = to_addrs # TODO not tested if it supports more emails 2025-06-25 part1 = MIMEText("Thanks for using cha.fan", "plain") part2 = MIMEText(html_body, "html") msg.attach(part1) msg.attach(part2) return msg + def login(self): self.smtp = "Mock_Smtp" + def quit(self): self.smtp = None - def send_email(self, from_addr, to_addr, subject, text): print("Dry-run: send email") if self.smtp is None: diff --git a/chafan_core/app/email/smtp_client.py b/chafan_core/app/email/smtp_client.py index 0189238..034d3b8 100644 --- a/chafan_core/app/email/smtp_client.py +++ b/chafan_core/app/email/smtp_client.py @@ -1,11 +1,13 @@ -from chafan_core.app.config import settings -from chafan_core.app.email.mock_client import MockEmailClient +import logging import smtplib import ssl -import logging +from chafan_core.app.config import settings +from chafan_core.app.email.mock_client import MockEmailClient + logger = logging.getLogger(__name__) + class SmtpClient(MockEmailClient): smtp = None host = None @@ -13,6 +15,7 @@ class SmtpClient(MockEmailClient): username = None password = None debug = False + def login(self): server = smtplib.SMTP(self.host, self.port, timeout=30) if self.debug: @@ -23,6 +26,7 @@ def login(self): server.login(self.username, self.password) server.ehlo() self.smtp = server + def __init__(self, debug=False): self.host = settings.EMAIL_SMTP_HOST self.port = settings.EMAIL_SMTP_PORT @@ -46,5 +50,3 @@ def quit(self): return self.smtp.quit() self.smtp = None - - diff --git a/chafan_core/app/email/utils.py b/chafan_core/app/email/utils.py index dc5f474..5547019 100644 --- a/chafan_core/app/email/utils.py +++ b/chafan_core/app/email/utils.py @@ -1,37 +1,38 @@ -from typing import Dict, Any - -from jinja2 import Template,StrictUndefined - +import logging +from typing import Any, Dict from urllib.parse import urlencode +from jinja2 import StrictUndefined, Template + from chafan_core.app.config import settings -from chafan_core.app.email.smtp_client import SmtpClient from chafan_core.app.email.mock_client import MockEmailClient +from chafan_core.app.email.smtp_client import SmtpClient -import logging logger = logging.getLogger(__name__) - -def apply_email_template(template_name:str, - environment: Dict[str, Any] = {}, - allow_undefined:bool = False) -> str: - html_template_path = "{}/{}.html".format(settings.EMAIL_TEMPLATES_DIR, template_name) +def apply_email_template( + template_name: str, environment: Dict[str, Any] = {}, allow_undefined: bool = False +) -> str: + html_template_path = "{}/{}.html".format( + settings.EMAIL_TEMPLATES_DIR, template_name + ) with open(html_template_path) as f: template_str = f.read() if allow_undefined: jinja = Template(template_str) else: - jinja = Template(template_str,undefined=StrictUndefined) + jinja = Template(template_str, undefined=StrictUndefined) return jinja.render(environment) + async def send_verification_code_email(email: str, code: str) -> None: project_name = settings.PROJECT_NAME subject = f"{project_name} - 验证码 {code}" server_host = str(settings.SERVER_HOST).strip("/") params = {"email": email, "code": code} link = f"{server_host}/signup?{urlencode(params)}" - environment={ + environment = { "project_name": settings.PROJECT_NAME, "email": email, "valid_hours": settings.EMAIL_SIGNUP_CODE_EXPIRE_HOURS, @@ -45,17 +46,18 @@ async def send_verification_code_email(email: str, code: str) -> None: html_body=html_body, ) + async def send_reset_password_email(email: str, token: str) -> None: project_name = settings.PROJECT_NAME subject = f"{project_name} - 密码重置 {email}" server_host = str(settings.SERVER_HOST).strip("/") link = f"{server_host}/reset-password?token={token}" - environment={ - "project_name": settings.PROJECT_NAME, - "username": email, - "email": email, - "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, - "link": link, + environment = { + "project_name": settings.PROJECT_NAME, + "username": email, + "email": email, + "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, + "link": link, } html_body = apply_email_template("reset_password", environment) logger.info(f"Send reset password email to {email}") @@ -65,11 +67,12 @@ async def send_reset_password_email(email: str, token: str) -> None: html_body=html_body, ) + async def send_email( email_to: str, subject: str = "", html_body: str = "", - ): +): # TODO I should move it out of send_email, but use dependency injection. 2025-Jul-04 if settings.EMAILS_ENABLED: client = SmtpClient() @@ -79,4 +82,3 @@ async def send_email( client.login() client.send_email("admin@cha.fan", email_to, subject, html_body) client.quit() - diff --git a/chafan_core/app/email_utils.py b/chafan_core/app/email_utils.py index 96dcd15..68a38b7 100644 --- a/chafan_core/app/email_utils.py +++ b/chafan_core/app/email_utils.py @@ -4,13 +4,15 @@ import tempfile from pathlib import Path from typing import Any, Dict, List - -#from emails.template import JinjaTemplate # type: ignore +from urllib.parse import urlencode from chafan_core.app import schemas from chafan_core.app.common import from_now, is_dev, render_notif_content from chafan_core.app.config import settings +# from emails.template import JinjaTemplate # type: ignore + + # TODO this file should be moved into chafan_core/app/email_util @@ -24,9 +26,6 @@ def send_email( return - - - def send_notification_email( email: str, notifications: List[schemas.Notification], @@ -75,7 +74,6 @@ def send_verification_code_phone_number(_phone_number: str, _code: str) -> None: raise NotImplementedError("No longer support SMS") - def send_new_account_email(email_to: str, username: str, password: str) -> None: project_name = settings.PROJECT_NAME subject = f"{project_name} - New account for user {username}" diff --git a/chafan_core/app/feed.py b/chafan_core/app/feed.py index b4404c4..9f60660 100644 --- a/chafan_core/app/feed.py +++ b/chafan_core/app/feed.py @@ -1,9 +1,9 @@ -from typing import Dict, List, NamedTuple, Optional, Set, Any -import sentry_sdk import json +from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Set + +import sentry_sdk from sqlalchemy.orm import Session -from chafan_core.db.base_class import Base as BaseCrudModel from chafan_core.app import crud, models, schemas from chafan_core.app.data_broker import DataBroker from chafan_core.app.materialize import Materializer @@ -27,14 +27,15 @@ UpvoteSubmissionInternal, ) from chafan_core.app.task_utils import execute_with_broker, execute_with_db +from chafan_core.db.base_class import Base as BaseCrudModel from chafan_core.db.session import ReadSessionLocal, SessionLocal from chafan_core.utils.base import map_, unwrap -from typing import TYPE_CHECKING if TYPE_CHECKING: from chafan_core.app.cached_layer import CachedLayer import logging + logger = logging.getLogger(__name__) @@ -43,9 +44,9 @@ class ActivityDistributionInfo(NamedTuple): subject_user_uuid: Optional[str] - - -def lookup_activity_receiver_list(broker: DataBroker, activity: models.Activity)->ActivityDistributionInfo: +def lookup_activity_receiver_list( + broker: DataBroker, activity: models.Activity +) -> ActivityDistributionInfo: try: event = EventInternal.parse_raw(activity.event_json) except Exception: @@ -66,16 +67,19 @@ def lookup_activity_receiver_list(broker: DataBroker, activity: models.Activity) receiver_ids=set(receivers.keys()), subject_user_uuid=subject_user_uuid ) -def new_activity_into_feed(broker: DataBroker, activity:models.Activity) -> None: - logger.info("generating feed for activity " + str(activity.id)) + +def new_activity_into_feed(broker: DataBroker, activity: models.Activity) -> None: + logger.info("generating feed for activity " + str(activity.id)) assert activity.id is not None assert isinstance(activity.id, int) receivers = lookup_activity_receiver_list(broker, activity) write_db = broker.get_db() for receiver_id in receivers.receiver_ids: - feed = write_db.query(models.Feed) \ - .filter_by(receiver_id=receiver_id, activity_id=activity.id) \ + feed = ( + write_db.query(models.Feed) + .filter_by(receiver_id=receiver_id, activity_id=activity.id) .first() + ) if feed is None: write_db.add( models.Feed( @@ -87,7 +91,6 @@ def new_activity_into_feed(broker: DataBroker, activity:models.Activity) -> None write_db.commit() - # TODO This is the v1 api. To be removed. 2025-07-19 def get_activity_dist_info( read_db: Session, activity: models.Activity @@ -234,6 +237,7 @@ def materialize_activity( return activity_data return None + # This is not good OOP practice, but doing it here can avoid making event.py too complex. 2025-Aug-13 def retrieve_content(event: EventInternal, cached_layer) -> Optional[BaseCrudModel]: assert isinstance(event, EventInternal) @@ -246,8 +250,7 @@ def retrieve_content(event: EventInternal, cached_layer) -> Optional[BaseCrudMod return question if isinstance(c, AnswerQuestionInternal): answer = cached_layer.get_answer_by_id(c.answer_id) - if (answer.is_hidden_by_moderator) or \ - (not answer.is_published): + if (answer.is_hidden_by_moderator) or (not answer.is_published): logger.warning("Skip a hidden answer: " + str(answer)) return None return answer @@ -256,20 +259,20 @@ def retrieve_content(event: EventInternal, cached_layer) -> Optional[BaseCrudMod return art logger.error(f"Not supported event type: {event}") - return None #TODO throw exception + return None # TODO throw exception + async def get_content_from_eventjson( - cached_layer: "CachedLayer", - event_json: str) -> Optional[BaseCrudModel]: + cached_layer: "CachedLayer", event_json: str +) -> Optional[BaseCrudModel]: event = EventInternal.parse_raw(event_json) content = retrieve_content(event, cached_layer) return content + async def get_site_activities( - cached_layer: "CachedLayer", - site, - limit: int, - all_sites = False) -> List[BaseCrudModel]: + cached_layer: "CachedLayer", site, limit: int, all_sites=False +) -> List[BaseCrudModel]: db = cached_layer.get_db() if (site is None) and (not all_sites): raise ValueError("site not found ") @@ -287,6 +290,7 @@ async def get_site_activities( activities.append(obj) return activities + async def get_activities_v2( *, cached_layer: "CachedLayer", @@ -306,11 +310,13 @@ async def get_activities_v2( feeds = feeds.filter_by(receiver_id=receiver_user_id) if before_activity_id: feeds = feeds.filter(models.Feed.activity_id < before_activity_id) - feeds = feeds.order_by(models.Feed.activity_id.desc()).limit(limit * 2) # Do we have better idea? + feeds = feeds.order_by(models.Feed.activity_id.desc()).limit( + limit * 2 + ) # Do we have better idea? activities = [] activity_ids = set() for feed in feeds: - feed_settings = None # TODO not supported yed + feed_settings = None # TODO not supported yed if feed.activity_id in activity_ids: continue activity = materialize_activity( @@ -325,7 +331,7 @@ async def get_activities_v2( return activities -def get_activities( # TODO to remove this function +def get_activities( # TODO to remove this function *, before_activity_id: Optional[int], limit: int, @@ -408,6 +414,3 @@ def runnable(broker: DataBroker) -> List[schemas.Activity]: CACHE_REWIND_SIZE = 1000 - - - diff --git a/chafan_core/app/main.py b/chafan_core/app/main.py index da0e9c2..9870ded 100644 --- a/chafan_core/app/main.py +++ b/chafan_core/app/main.py @@ -1,7 +1,7 @@ -from typing import Any, MutableMapping, Optional - import logging import logging.config +from typing import Any, MutableMapping, Optional + log_config = { "version": 1, "disable_existing_loggers": False, @@ -23,7 +23,7 @@ "app": {"handlers": ["console"], "level": "INFO", "propagate": False}, }, "root": {"handlers": ["console"], "level": "INFO"}, -} # https://betterstack.com/community/guides/logging/logging-with-fastapi/#configuring-your-logging-system +} # https://betterstack.com/community/guides/logging/logging-with-fastapi/#configuring-your-logging-system logging.config.dictConfig(log_config) logger = logging.getLogger(__name__) @@ -32,10 +32,10 @@ from apscheduler.triggers.interval import IntervalTrigger scheduler = BackgroundScheduler() -import sentry_sdk -import uvicorn import fastapi +import sentry_sdk import starlette +import uvicorn from fastapi import FastAPI from fastapi.exception_handlers import request_validation_exception_handler from fastapi.exceptions import RequestValidationError @@ -50,23 +50,19 @@ from chafan_core.app.config import settings from chafan_core.app.limiter import limiter from chafan_core.app.limiter_middleware import SlowAPIMiddleware -from chafan_core.app.task import ( - write_view_count_to_db, - refresh_search_index, -) +from chafan_core.app.task import refresh_search_index, write_view_count_to_db args: MutableMapping[str, Optional[Any]] = {} if is_dev(): args["openapi_url"] = f"{settings.API_V1_STR}/openapi.json" else: args["openapi_url"] = None - #args["docs_url"] = None + # args["docs_url"] = None args["redoc_url"] = None app = FastAPI(title=settings.PROJECT_NAME, **args) # type: ignore - if enable_rate_limit(): app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) @@ -86,11 +82,12 @@ async def custom_validation_exception_handler( sentry_sdk.capture_message(err_msg) return await request_validation_exception_handler(request, exc) + def set_backend_cors_origins(): origins = [] - if settings.DEBUG_BYPASS_BACKEND_CORS == 'magic': - origins.append('*') - for host in settings.CHAFAN_BACKEND_CORS_ORIGINS.split(','): + if settings.DEBUG_BYPASS_BACKEND_CORS == "magic": + origins.append("*") + for host in settings.CHAFAN_BACKEND_CORS_ORIGINS.split(","): origins.append(host) app.add_middleware( CORSMiddleware, @@ -101,37 +98,44 @@ def set_backend_cors_origins(): ) logger.info("Set CORS allowed origins: " + str(origins)) + set_backend_cors_origins() app.include_router(health.router) app.include_router(api_router, prefix=settings.API_V1_STR) - - def print_app_settings() -> None: logger.info("settings:") for k, v in settings.__dict__.items(): if not k.startswith("__"): logger.info(f"{k}: {v}") + print_app_settings() for lib in [fastapi, uvicorn, starlette]: logger.info("{} version: {}".format(lib.__name__, lib.__version__)) logger.info("Server launches") + @app.on_event("startup") def set_up_scheduled_tasks(): if not scheduler.running: scheduler.add_job( - write_view_count_to_db, - trigger=IntervalTrigger(minutes=settings.SCHEDULED_TASK_UPDATE_VIEW_COUNT_MINUTES), - name="write_new_activities_to_feeds") + write_view_count_to_db, + trigger=IntervalTrigger( + minutes=settings.SCHEDULED_TASK_UPDATE_VIEW_COUNT_MINUTES + ), + name="write_new_activities_to_feeds", + ) scheduler.add_job( - refresh_search_index, - trigger=IntervalTrigger(hours=settings.SCHEDULED_TASK_REFRESH_SEARCH_INDEX_HOURS), - name="refresh_search_index") + refresh_search_index, + trigger=IntervalTrigger( + hours=settings.SCHEDULED_TASK_REFRESH_SEARCH_INDEX_HOURS + ), + name="refresh_search_index", + ) scheduler.start() logger.info("Set up scheduled tasks") else: @@ -141,4 +145,3 @@ def set_up_scheduled_tasks(): @app.on_event("shutdown") def shutdown_event(): logger.info("Stub: shutdown_event") - diff --git a/chafan_core/app/materialize.py b/chafan_core/app/materialize.py index a40c25d..11221e9 100644 --- a/chafan_core/app/materialize.py +++ b/chafan_core/app/materialize.py @@ -1,6 +1,7 @@ import datetime -from typing import Any, Dict, Mapping, Optional, Tuple, Union import logging +from typing import Any, Dict, Mapping, Optional, Tuple, Union + logger = logging.getLogger(__name__) import sentry_sdk @@ -28,10 +29,7 @@ EventInternal, ) from chafan_core.app.schemas.notification import Notification, NotificationInDBBase -from chafan_core.app.schemas.question import ( - QuestionInDBBase, - QuestionPreviewForSearch -) +from chafan_core.app.schemas.question import QuestionInDBBase, QuestionPreviewForSearch from chafan_core.app.schemas.reward import AnsweredQuestionCondition, RewardCondition from chafan_core.app.schemas.richtext import RichText from chafan_core.app.schemas.security import IntlPhoneNumber @@ -57,6 +55,8 @@ import chafan_core.app.user_permission as user_permission + + def get_active_site_profile( db: Session, *, site: models.Site, user_id: int ) -> Optional[models.Profile]: @@ -271,14 +271,10 @@ def user_schema_from_orm(user: models.User) -> schemas.User: async def preview_of_question_as_search_hit(question: models.Question): if not question.site.public_readable: return None - r = QuestionPreviewForSearch( - uuid = question.uuid, - title = question.title - ) + r = QuestionPreviewForSearch(uuid=question.uuid, title=question.title) return r - class Materializer(object): def __init__(self, broker: DataBroker, principal_id: Optional[int]): self.broker = broker @@ -301,7 +297,7 @@ def preview_of_user(self, user: models.User) -> schemas.UserPreview: ) def site_schema_from_orm(self, site: models.Site) -> schemas.Site: - #logger.error("TODO materialize site_schema_from_orm to be removed") + # logger.error("TODO materialize site_schema_from_orm to be removed") # This function SHOULD be removed. However, it's error log is so annoying. Turn it off for now. 2025-Aug-16 base = schemas.SiteInDBBase.from_orm(site) site_dict = base.dict() @@ -576,7 +572,7 @@ def article_schema_from_orm( d["bookmarked"] = article in principal.bookmarked_articles d["author"] = self.preview_of_user(article.author) d["upvoted"] = upvoted - d["view_times"] = 0#view_counters.get_views(article.uuid, "article") + d["view_times"] = 0 # view_counters.get_views(article.uuid, "article") d["archives_count"] = len(article.archives) if article.is_published: body = article.body @@ -786,7 +782,7 @@ def answer_for_visitor_schema_from_orm( return None d["question"] = q d["author"] = self.preview_of_user(answer.author) - d["view_times"] = 0 #view_counters.get_views(answer.uuid, "answer") + d["view_times"] = 0 # view_counters.get_views(answer.uuid, "answer") d["content"] = RichText( source=answer.body, rendered_text=answer.body_prerendered_text, @@ -828,7 +824,7 @@ def answer_schema_from_orm(self, answer: models.Answer) -> Optional[schemas.Answ principal = crud.user.get(db, id=self.principal_id) assert principal is not None d["bookmarked"] = answer in principal.bookmarked_answers - d["view_times"] = 0 #view_counters.get_views(answer.uuid, "answer") + d["view_times"] = 0 # view_counters.get_views(answer.uuid, "answer") if answer.is_published: body = answer.body else: @@ -875,7 +871,7 @@ def question_schema_from_orm( d["author"] = self.preview_of_user(question.author) d["editor"] = map_(question.editor, self.preview_of_user) d["upvoted"] = upvoted - d["view_times"] = 0 #view_counters.get_views(question.uuid, "question") + d["view_times"] = 0 # view_counters.get_views(question.uuid, "question") d["answers_count"] = len(get_live_answers_of_question(question)) if question.description is not None: d["desc"] = RichText( @@ -944,7 +940,7 @@ def submission_for_visitor_schema_from_orm( def submission_schema_from_orm( self, submission: models.Submission ) -> Optional[schemas.Submission]: - #logger.error("TODO submission_schema_from_orm in materialize.py is deprecated") + # logger.error("TODO submission_schema_from_orm in materialize.py is deprecated") # 2025-Sep-14 This log it too noisy if self.principal_id and not user_in_site( self.broker.get_db(), @@ -963,7 +959,7 @@ def submission_schema_from_orm( ) d["author"] = self.preview_of_user(submission.author) d["contributors"] = [self.preview_of_user(u) for u in submission.contributors] - d["view_times"] = 0 #view_counters.get_views(submission.uuid, "submission") + d["view_times"] = 0 # view_counters.get_views(submission.uuid, "submission") if submission.description is not None: d["desc"] = RichText( source=submission.description, diff --git a/chafan_core/app/models/__init__.py b/chafan_core/app/models/__init__.py index 28ebac1..58a77f8 100644 --- a/chafan_core/app/models/__init__.py +++ b/chafan_core/app/models/__init__.py @@ -33,5 +33,10 @@ from .task import Task from .topic import Topic from .user import User +from .viewcount import ( + ViewCountAnswer, + ViewCountArticle, + ViewCountQuestion, + ViewCountSubmission, +) from .webhook import Webhook -from .viewcount import ViewCountArticle, ViewCountAnswer, ViewCountQuestion, ViewCountSubmission diff --git a/chafan_core/app/models/viewcount.py b/chafan_core/app/models/viewcount.py index 3c89e60..3d82c7e 100644 --- a/chafan_core/app/models/viewcount.py +++ b/chafan_core/app/models/viewcount.py @@ -1,4 +1,3 @@ - from sqlalchemy import ( Column, ForeignKey, @@ -10,10 +9,10 @@ from chafan_core.db.base_class import Base - -#if TYPE_CHECKING: +# if TYPE_CHECKING: # from . import * # noqa: F401, F403 + class ViewCountArticle(Base): __table_args__ = ( UniqueConstraint("article_id"), @@ -22,6 +21,7 @@ class ViewCountArticle(Base): article_id = Column(Integer, ForeignKey("article.id"), nullable=False, index=True) view_count = Column(Integer, default=0, server_default="0", nullable=False) + class ViewCountQuestion(Base): __table_args__ = ( UniqueConstraint("question_id"), @@ -30,6 +30,7 @@ class ViewCountQuestion(Base): question_id = Column(Integer, ForeignKey("question.id"), nullable=True, index=True) view_count = Column(Integer, default=0, server_default="0", nullable=False) + class ViewCountAnswer(Base): __table_args__ = ( UniqueConstraint("answer_id"), @@ -38,10 +39,13 @@ class ViewCountAnswer(Base): answer_id = Column(Integer, ForeignKey("answer.id"), nullable=True, index=True) view_count = Column(Integer, default=0, server_default="0", nullable=False) + class ViewCountSubmission(Base): __table_args__ = ( UniqueConstraint("submission_id"), PrimaryKeyConstraint("submission_id"), ) - submission_id = Column(Integer, ForeignKey("submission.id"), nullable=True, index=True) + submission_id = Column( + Integer, ForeignKey("submission.id"), nullable=True, index=True + ) view_count = Column(Integer, default=0, server_default="0", nullable=False) diff --git a/chafan_core/app/mq.py b/chafan_core/app/mq.py index 304c17f..72dd625 100644 --- a/chafan_core/app/mq.py +++ b/chafan_core/app/mq.py @@ -1,14 +1,15 @@ - import logging + logger = logging.getLogger(__name__) +import logging + from chafan_core.app import models from chafan_core.app.data_broker import DataBroker from chafan_core.app.materialize import Materializer from chafan_core.app.schemas.mq import WsUserMsg -import logging logger = logging.getLogger(__name__) @@ -25,9 +26,8 @@ def push_notification(data_broker: DataBroker, *, notif: models.Notification) -> return queue_name = get_ws_queue_for_user(notif.receiver_id) msg = WsUserMsg( - type="notification", - data=n, + type="notification", + data=n, ) redis = data_broker.get_redis() redis.rpush(queue_name, msg.json()) - diff --git a/chafan_core/app/recs/ranking.py b/chafan_core/app/recs/ranking.py index 1b16eb2..afed3d2 100644 --- a/chafan_core/app/recs/ranking.py +++ b/chafan_core/app/recs/ranking.py @@ -33,12 +33,12 @@ def freshness( def rank_submissions( - submissions: Union[List[schemas.Submission], List[schemas.SubmissionForVisitor]] + submissions: Union[List[schemas.Submission], List[schemas.SubmissionForVisitor]], ) -> Union[List[schemas.Submission], List[schemas.SubmissionForVisitor]]: utc_now = datetime.datetime.now(tz=datetime.timezone.utc) def hotness( - submission: Union[schemas.Submission, schemas.SubmissionForVisitor] + submission: Union[schemas.Submission, schemas.SubmissionForVisitor], ) -> float: return float(submission.upvotes_count + 1) * freshness( utc_now, submission.updated_at, recency_boost=1 diff --git a/chafan_core/app/rep_manager.py b/chafan_core/app/rep_manager.py index adff57c..3ca5f6e 100644 --- a/chafan_core/app/rep_manager.py +++ b/chafan_core/app/rep_manager.py @@ -1,3 +1,4 @@ +import logging from typing import Optional from sqlalchemy.orm import Session @@ -7,8 +8,6 @@ from chafan_core.app.config import settings from chafan_core.utils.base import ContentVisibility - -import logging logger = logging.getLogger(__name__) # TODO everything about user permission, including if they can create a site (KARMA), invite a user, write an answer, etc, should be moved into this file. 2025-07-08 @@ -19,20 +18,26 @@ def new_submission_suggestion(submission_suggestion): pass + def new_question(question): pass + def new_submission(submission): pass + def accept_submission_suggestion(submission_suggestion): pass + def new_answer_suggest(answer_suggest): pass + def accept_answer_suggest(answer_suggest_edit): pass + def new_article(article): pass diff --git a/chafan_core/app/responders/__init__.py b/chafan_core/app/responders/__init__.py index b669e8d..ba1498e 100644 --- a/chafan_core/app/responders/__init__.py +++ b/chafan_core/app/responders/__init__.py @@ -1,4 +1,4 @@ -from . import question, submission, article, answer, site, rss +from . import answer, article, question, rss, site, submission # responders 的设计目的是 models (crud) -> schema (api endpoint) # 1. responders 不需要鉴权 diff --git a/chafan_core/app/responders/answer.py b/chafan_core/app/responders/answer.py index ba422a1..2b5dd6e 100644 --- a/chafan_core/app/responders/answer.py +++ b/chafan_core/app/responders/answer.py @@ -1,22 +1,19 @@ +import logging from typing import Any, Dict, Mapping, Optional, Tuple, Union -from chafan_core.app import models, schemas -from chafan_core.app.schemas.richtext import RichText -from chafan_core.utils.base import ( - filter_not_none, -) - -from chafan_core.app import view_counters - - +from chafan_core.app import models, schemas, view_counters from chafan_core.app.schemas.answer import AnswerInDBBase +from chafan_core.app.schemas.richtext import RichText +from chafan_core.utils.base import filter_not_none -import logging logger = logging.getLogger(__name__) -def answer_schema_from_orm(cached_layer, answer: models.Answer, principal_id) -> Optional[schemas.Answer]: + +def answer_schema_from_orm( + cached_layer, answer: models.Answer, principal_id +) -> Optional[schemas.Answer]: db = cached_layer.broker.get_db() - #if not can_read_answer(db, answer=answer, principal_id=self.principal_id): + # if not can_read_answer(db, answer=answer, principal_id=self.principal_id): # return None upvoted = ( db.query(models.Answer_Upvotes) @@ -25,12 +22,12 @@ def answer_schema_from_orm(cached_layer, answer: models.Answer, principal_id) -> is not None ) # TODO skipped the permission check -# comment_writable = user_in_site( -# db, -# site=answer.site, -# user_id=self.principal_id, -# op_type=OperationType.WriteSiteComment, -# ) + # comment_writable = user_in_site( + # db, + # site=answer.site, + # user_id=self.principal_id, + # op_type=OperationType.WriteSiteComment, + # ) base = AnswerInDBBase.from_orm(answer) d = base.dict() d["site"] = cached_layer.site_schema_from_orm(answer.site) @@ -40,12 +37,12 @@ def answer_schema_from_orm(cached_layer, answer: models.Answer, principal_id) -> d["author"] = cached_layer.materializer.preview_of_user(answer.author) d["question"] = cached_layer.materializer.preview_of_question(answer.question) d["upvoted"] = upvoted - d["comment_writable"] = True #comment_writable + d["comment_writable"] = True # comment_writable d["bookmark_count"] = answer.bookmarkers.count() d["archives_count"] = len(answer.archives) - #principal = crud.user.get(db, id=principal_id) - #assert principal is not None - d["bookmarked"] = True #answer in principal.bookmarked_answers + # principal = crud.user.get(db, id=principal_id) + # assert principal is not None + d["bookmarked"] = True # answer in principal.bookmarked_answers d["view_times"] = view_counters.get_viewcount_answer(cached_layer.broker, answer.id) if answer.is_published: body = answer.body @@ -62,4 +59,3 @@ def answer_schema_from_orm(cached_layer, answer: models.Answer, principal_id) -> ) d["suggest_editable"] = answer.body_draft is None return schemas.Answer(**d) - diff --git a/chafan_core/app/responders/article.py b/chafan_core/app/responders/article.py index 7fdd6b5..a7fe444 100644 --- a/chafan_core/app/responders/article.py +++ b/chafan_core/app/responders/article.py @@ -1,18 +1,13 @@ -from typing import Optional import logging -logger = logging.getLogger(__name__) +from typing import Optional -from chafan_core.app import crud, models, schemas -from chafan_core.app.schemas.richtext import RichText -from chafan_core.utils.base import ( - filter_not_none, -) +logger = logging.getLogger(__name__) +from chafan_core.app import crud, models, schemas, view_counters from chafan_core.app.schemas.article import ArticleInDB from chafan_core.app.schemas.article_archive import ArticleArchiveInDB - - -from chafan_core.app import view_counters +from chafan_core.app.schemas.richtext import RichText +from chafan_core.utils.base import filter_not_none def article_schema_from_orm( @@ -22,9 +17,7 @@ def article_schema_from_orm( upvoted = ( cached_layer.broker.get_db() .query(models.ArticleUpvotes) - .filter_by( - article_id=article.id, voter_id=principal_id, cancelled=False - ) + .filter_by(article_id=article.id, voter_id=principal_id, cancelled=False) .first() is not None ) @@ -38,12 +31,14 @@ def article_schema_from_orm( ) d["bookmark_count"] = article.bookmarkers.count() principal = crud.user.get(cached_layer.broker.get_db(), id=principal_id) - #assert principal is not None - #d["bookmarked"] = article in principal.bookmarked_articles - d["bookmarked"] = True # TODO leave it for now 2025-07-24 + # assert principal is not None + # d["bookmarked"] = article in principal.bookmarked_articles + d["bookmarked"] = True # TODO leave it for now 2025-07-24 d["author"] = cached_layer.materializer.preview_of_user(article.author) d["upvoted"] = upvoted - d["view_times"] = view_counters.get_viewcount_article(cached_layer.broker, article.id) + d["view_times"] = view_counters.get_viewcount_article( + cached_layer.broker, article.id + ) d["archives_count"] = len(article.archives) if article.is_published: body = article.body diff --git a/chafan_core/app/responders/question.py b/chafan_core/app/responders/question.py index 5411543..50f3f16 100644 --- a/chafan_core/app/responders/question.py +++ b/chafan_core/app/responders/question.py @@ -1,28 +1,19 @@ -from typing import Any, Dict, Mapping, Optional, Tuple, Union import logging +from typing import Any, Dict, Mapping, Optional, Tuple, Union + logger = logging.getLogger(__name__) from sqlalchemy.orm import Session import chafan_core.app.responders as responders -from chafan_core.app import models, schemas +from chafan_core.app import models, schemas, view_counters from chafan_core.app.common import OperationType, is_dev from chafan_core.app.data_broker import DataBroker -from chafan_core.app.model_utils import ( - get_live_answers_of_question, -) -from chafan_core.app.schemas.question import ( - QuestionInDBBase, - QuestionPreviewForSearch -) +from chafan_core.app.model_utils import get_live_answers_of_question +from chafan_core.app.schemas.question import QuestionInDBBase, QuestionPreviewForSearch from chafan_core.app.schemas.richtext import RichText -from chafan_core.utils.base import ( - filter_not_none, - map_, - unwrap, -) +from chafan_core.utils.base import filter_not_none, map_, unwrap -from chafan_core.app import view_counters def user_in_site( db: Session, @@ -36,14 +27,14 @@ def user_in_site( def question_schema_from_orm( - broker: DataBroker, - principal_id, - question: models.Question, - cached_layer # TODO we should remove this dependency in future 2025-07-23 + broker: DataBroker, + principal_id, + question: models.Question, + cached_layer, # TODO we should remove this dependency in future 2025-07-23 ) -> Optional[schemas.Question]: if not principal_id: logger.error("TODO skipped principle_id check") - #return None + # return None if not user_in_site( broker.get_db(), site=question.site, @@ -55,9 +46,7 @@ def question_schema_from_orm( upvoted = ( broker.get_db() .query(models.QuestionUpvotes) - .filter_by( - question_id=question.id, voter_id=principal_id, cancelled=False - ) + .filter_by(question_id=question.id, voter_id=principal_id, cancelled=False) .first() is not None ) @@ -65,7 +54,10 @@ def question_schema_from_orm( d = base.dict() d["site"] = responders.site.site_schema_from_orm(cached_layer, question.site) d["comments"] = filter_not_none( - [cached_layer.materializer.comment_schema_from_orm(c) for c in question.comments] + [ + cached_layer.materializer.comment_schema_from_orm(c) + for c in question.comments + ] ) d["author"] = cached_layer.materializer.preview_of_user(question.author) d["editor"] = map_(question.editor, cached_layer.materializer.preview_of_user) diff --git a/chafan_core/app/responders/rss.py b/chafan_core/app/responders/rss.py index 921c928..daf95da 100644 --- a/chafan_core/app/responders/rss.py +++ b/chafan_core/app/responders/rss.py @@ -1,14 +1,15 @@ -from feedgen.feed import FeedGenerator - +import logging from typing import List -from chafan_core.app.models import Answer, Question, Article +from feedgen.feed import FeedGenerator + from chafan_core.app.config import settings +from chafan_core.app.models import Answer, Article, Question -import logging logger = logging.getLogger(__name__) -def build_rss(activities: List, site)->str: + +def build_rss(activities: List, site) -> str: fg = FeedGenerator() if site is not None: fg.title("ChaFan RSS " + site.name) @@ -25,7 +26,7 @@ def build_rss(activities: List, site)->str: verb = "内容" user = ac.author.full_name if user is None or user == "": - user ="茶饭用户" + user = "茶饭用户" link = "https://cha.fan" description = "内容" @@ -50,7 +51,6 @@ def build_rss(activities: List, site)->str: else: logger.error(f"Not supported item: {ac}") - title = f"{user} 发表了{verb}" fe.title(title) fe.link(href=link) diff --git a/chafan_core/app/responders/site.py b/chafan_core/app/responders/site.py index bb64127..c2381d0 100644 --- a/chafan_core/app/responders/site.py +++ b/chafan_core/app/responders/site.py @@ -1,7 +1,8 @@ +import logging from typing import Any, Mapping + from chafan_core.app import models, schemas -import logging logger = logging.getLogger(__name__) _VISIBLE_QUESTION_CONDITIONS = { @@ -11,9 +12,11 @@ "is_hidden": False, } + def keep_items(questions: Any, conditions: Mapping[str, Any]) -> Any: return questions.filter_by(**conditions) + def site_schema_from_orm(cached_layer, site: models.Site) -> schemas.Site: base = schemas.SiteInDBBase.from_orm(site) site_dict = base.dict() diff --git a/chafan_core/app/responders/submission.py b/chafan_core/app/responders/submission.py index 938e878..b9c6319 100644 --- a/chafan_core/app/responders/submission.py +++ b/chafan_core/app/responders/submission.py @@ -1,17 +1,14 @@ +import logging from typing import Optional -from chafan_core.app import models, schemas -from chafan_core.app.schemas.richtext import RichText -from chafan_core.utils.base import ( - filter_not_none, -) - import chafan_core.app.responders as responders -from chafan_core.app import view_counters +from chafan_core.app import models, schemas, view_counters +from chafan_core.app.schemas.richtext import RichText +from chafan_core.utils.base import filter_not_none -import logging logger = logging.getLogger(__name__) + def submission_schema_from_orm( cached_layer, submission: models.Submission, @@ -22,11 +19,18 @@ def submission_schema_from_orm( d = base.dict() d["site"] = responders.site.site_schema_from_orm(cached_layer, submission.site) d["comments"] = filter_not_none( - [cached_layer.materializer.comment_schema_from_orm(c) for c in submission.comments] + [ + cached_layer.materializer.comment_schema_from_orm(c) + for c in submission.comments + ] ) d["author"] = cached_layer.preview_of_user(submission.author) - d["contributors"] = [cached_layer.preview_of_user(u) for u in submission.contributors] - d["view_times"] = view_counters.get_viewcount_submission(cached_layer.broker, submission.id) + d["contributors"] = [ + cached_layer.preview_of_user(u) for u in submission.contributors + ] + d["view_times"] = view_counters.get_viewcount_submission( + cached_layer.broker, submission.id + ) if submission.description is not None: d["desc"] = RichText( source=submission.description, @@ -34,4 +38,3 @@ def submission_schema_from_orm( editor=submission.description_editor, ) return schemas.Submission(**d) - diff --git a/chafan_core/app/schemas/__init__.py b/chafan_core/app/schemas/__init__.py index eea8697..ef5d1d6 100644 --- a/chafan_core/app/schemas/__init__.py +++ b/chafan_core/app/schemas/__init__.py @@ -93,8 +93,8 @@ QuestionForVisitor, QuestionInDB, QuestionPreview, - QuestionPreviewForVisitor, QuestionPreviewForSearch, + QuestionPreviewForVisitor, QuestionUpdate, QuestionUpvotes, ) diff --git a/chafan_core/app/schemas/article.py b/chafan_core/app/schemas/article.py index 93688ab..8691ed5 100644 --- a/chafan_core/app/schemas/article.py +++ b/chafan_core/app/schemas/article.py @@ -1,7 +1,7 @@ import datetime from typing import List, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel from chafan_core.app.schemas.article_column import ArticleColumn from chafan_core.app.schemas.comment import Comment, CommentForVisitor @@ -9,7 +9,7 @@ from chafan_core.app.schemas.richtext import RichText from chafan_core.app.schemas.topic import Topic from chafan_core.utils.base import ContentVisibility -from chafan_core.utils.validators import StrippedNonEmptyStr, validate_article_title +from chafan_core.utils.validators import ArticleTitle, StrippedNonEmptyStr # Shared properties @@ -19,32 +19,21 @@ class ArticleBase(BaseModel): # Properties to receive via API on creation class ArticleCreate(ArticleBase): - title: StrippedNonEmptyStr + title: ArticleTitle content: RichText article_column_uuid: str is_published: bool writing_session_uuid: str visibility: ContentVisibility - @validator("title") - def _valid_title(cls, v: str) -> str: - validate_article_title(v) - return v - # Properties to receive via API on update class ArticleUpdate(ArticleBase): - updated_title: StrippedNonEmptyStr + updated_title: ArticleTitle updated_content: RichText is_draft: bool visibility: ContentVisibility - @validator("updated_title") - def _valid_title(cls, v: Optional[str]) -> Optional[str]: - if v is not None: - validate_article_title(v) - return v - # Properties to receive via API on update class ArticleTopicsUpdate(ArticleBase): diff --git a/chafan_core/app/schemas/event.py b/chafan_core/app/schemas/event.py index 529551b..6ddb604 100644 --- a/chafan_core/app/schemas/event.py +++ b/chafan_core/app/schemas/event.py @@ -473,9 +473,9 @@ class InviteJoinSite(BaseModel): Site ] # TODO: Must be not-null. Delete non-conformant data after a while. user: Optional[UserPreview] - invited_email: Optional[ - CaseInsensitiveEmailStr - ] = None # Deprecated, see `InviteNewUser` + invited_email: Optional[CaseInsensitiveEmailStr] = ( + None # Deprecated, see `InviteNewUser` + ) def _string_args(self) -> Mapping[str, StringArg]: return { @@ -493,9 +493,9 @@ class InviteJoinSiteInternal(BaseModel): int ] # TODO: Must be not-null. Delete non-conformant data after a while. user_id: Optional[int] - invited_email: Optional[ - CaseInsensitiveEmailStr - ] = None # Deprecated, see `InviteNewUser` + invited_email: Optional[CaseInsensitiveEmailStr] = ( + None # Deprecated, see `InviteNewUser` + ) class SystemSendInvitation(BaseModel): diff --git a/chafan_core/app/schemas/form.py b/chafan_core/app/schemas/form.py index 1384735..b66e604 100644 --- a/chafan_core/app/schemas/form.py +++ b/chafan_core/app/schemas/form.py @@ -1,7 +1,7 @@ import datetime from typing import List, Literal, Optional, Union -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator from chafan_core.app.schemas.preview import UserPreview @@ -63,9 +63,11 @@ class FormCreate(FormBase): title: str form_fields: List[FormField] - @validator("form_fields") + @field_validator("form_fields") + @classmethod def _valid_form_fields(cls, v: List[FormField]) -> List[FormField]: - assert len(set(f.unique_name for f in v)) == len(v) + if len(set(f.unique_name for f in v)) != len(v): + raise ValueError("Form field names must be unique") return v diff --git a/chafan_core/app/schemas/form_response.py b/chafan_core/app/schemas/form_response.py index d17ea62..b028df9 100644 --- a/chafan_core/app/schemas/form_response.py +++ b/chafan_core/app/schemas/form_response.py @@ -1,7 +1,7 @@ import datetime from typing import List, Literal, Union -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator from chafan_core.app.schemas.form import Form from chafan_core.app.schemas.preview import UserPreview @@ -19,18 +19,18 @@ class TextResponseField(BaseModel): class SingleChoiceResponseField(BaseModel): - field_type_name: Literal[ + field_type_name: Literal["single_choice_response_field"] = ( "single_choice_response_field" - ] = "single_choice_response_field" + ) desc: str # TODO: validate selected_choice: str class MultipleChoiceResponseField(BaseModel): - field_type_name: Literal[ + field_type_name: Literal["multiple_choices_response_field"] = ( "multiple_choices_response_field" - ] = "multiple_choices_response_field" + ) desc: str # TODO: validate selected_choices: List[str] @@ -48,11 +48,13 @@ class FormResponseCreate(FormResponseBase): form_uuid: str response_fields: List[FormResponseField] - @validator("response_fields") + @field_validator("response_fields") + @classmethod def _valid_response_fields( cls, v: List[FormResponseField] ) -> List[FormResponseField]: - assert len(set(f.unique_name for f in v)) == len(v) + if len(set(f.unique_name for f in v)) != len(v): + raise ValueError("Response field names must be unique") return v diff --git a/chafan_core/app/schemas/message.py b/chafan_core/app/schemas/message.py index 20cd356..31b6ea7 100644 --- a/chafan_core/app/schemas/message.py +++ b/chafan_core/app/schemas/message.py @@ -1,20 +1,15 @@ import datetime -from pydantic import BaseModel, validator +from pydantic import BaseModel from chafan_core.app.schemas.preview import UserPreview -from chafan_core.utils.validators import validate_message_body +from chafan_core.utils.validators import MessageBody # Shared properties class MessageBase(BaseModel): channel_id: int - body: str - - @validator("body") - def _valid_body(cls, v: str) -> str: - validate_message_body(v) - return v + body: MessageBody # Properties to receive via API on creation diff --git a/chafan_core/app/schemas/question.py b/chafan_core/app/schemas/question.py index 70383eb..73959cb 100644 --- a/chafan_core/app/schemas/question.py +++ b/chafan_core/app/schemas/question.py @@ -1,14 +1,14 @@ import datetime from typing import List, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel from chafan_core.app.schemas.comment import Comment, CommentForVisitor from chafan_core.app.schemas.preview import UserPreview from chafan_core.app.schemas.richtext import RichText from chafan_core.app.schemas.site import Site from chafan_core.app.schemas.topic import Topic -from chafan_core.utils.validators import StrippedNonEmptyStr, validate_question_title +from chafan_core.utils.validators import QuestionTitle, StrippedNonEmptyStr # Shared properties @@ -19,28 +19,15 @@ class QuestionBase(BaseModel): # Properties to receive via API on creation class QuestionCreate(QuestionBase): site_uuid: str - title: StrippedNonEmptyStr - - @validator("title") - def _valid_title(cls, v: StrippedNonEmptyStr) -> StrippedNonEmptyStr: - validate_question_title(v) - return v + title: QuestionTitle # Properties to receive via API on update class QuestionUpdate(QuestionBase): - title: Optional[StrippedNonEmptyStr] = None + title: Optional[QuestionTitle] = None desc: Optional[RichText] = None topic_uuids: Optional[List[str]] = None - @validator("title") - def _valid_title( - cls, v: Optional[StrippedNonEmptyStr] - ) -> Optional[StrippedNonEmptyStr]: - if v is not None: - validate_question_title(v) - return v - class QuestionInDBBase(QuestionBase): uuid: str @@ -91,7 +78,8 @@ class QuestionPreviewForSearch(BaseModel): uuid: str title: str -#class QuestionPreviewForVisitor(QuestionPreviewForSearch): + +# class QuestionPreviewForVisitor(QuestionPreviewForSearch): class QuestionPreviewForVisitor(BaseModel): uuid: str title: str diff --git a/chafan_core/app/schemas/reward.py b/chafan_core/app/schemas/reward.py index a6571ec..91e5abf 100644 --- a/chafan_core/app/schemas/reward.py +++ b/chafan_core/app/schemas/reward.py @@ -1,7 +1,7 @@ import datetime -from typing import Literal, Optional +from typing import Annotated, Literal, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel, Field from chafan_core.app.schemas.preview import UserPreview @@ -22,24 +22,12 @@ class RewardCondition(BaseModel): # Properties to receive via API on creation class RewardCreate(RewardBase): - expired_after_days: int + expired_after_days: Annotated[int, Field(gt=0, description="Expiry days")] receiver_uuid: str - coin_amount: int + coin_amount: Annotated[int, Field(gt=0, description="Coin amount")] note_to_receiver: Optional[str] = None condition: Optional[RewardCondition] = None - @validator("coin_amount") - def _valid_coin_amount(cls, v: int) -> int: - if v <= 0: - raise ValueError("Invalid coin amount.") - return v - - @validator("expired_after_days") - def _valid_expired_after_days(cls, v: int) -> int: - if v <= 0: - raise ValueError("Invalid expiry days.") - return v - class RewardUpdate(BaseModel): pass diff --git a/chafan_core/app/schemas/security.py b/chafan_core/app/schemas/security.py index 675d424..2e4da2f 100644 --- a/chafan_core/app/schemas/security.py +++ b/chafan_core/app/schemas/security.py @@ -1,25 +1,17 @@ from typing import Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel -from chafan_core.utils.validators import CaseInsensitiveEmailStr +from chafan_core.utils.validators import ( + CaseInsensitiveEmailStr, + CountryCode, + SubscriberNumber, +) class IntlPhoneNumber(BaseModel): - country_code: str - subscriber_number: str - - @validator("country_code") - def _valid_country_code(cls, v: str) -> str: - if v.isdigit() and len(v) >= 1 and len(v) <= 3: - return v - raise ValueError(f"Invalid country code: {v}") - - @validator("subscriber_number") - def _valid_subscriber_number(cls, v: str) -> str: - if v.isdigit() and len(v) >= 1 and len(v) <= 12: - return v - raise ValueError(f"Invalid subscriber number: {v}") + country_code: CountryCode + subscriber_number: SubscriberNumber def format_e164(self) -> str: return f"+{self.country_code}{self.subscriber_number}" diff --git a/chafan_core/app/schemas/site.py b/chafan_core/app/schemas/site.py index 027866d..5a30349 100644 --- a/chafan_core/app/schemas/site.py +++ b/chafan_core/app/schemas/site.py @@ -1,14 +1,15 @@ -from typing import Any, Dict, List, Literal, Optional +import logging +from typing import Any, Dict, List, Literal, Optional, Self -from pydantic import BaseModel, validator +from pydantic import BaseModel, model_validator from chafan_core.app.schemas.preview import UserPreview from chafan_core.app.schemas.topic import Topic from chafan_core.utils.validators import StrippedNonEmptyBasicStr, StrippedNonEmptyStr -import logging logger = logging.getLogger(__name__) + # Shared properties class SiteBase(BaseModel): description: Optional[str] @@ -63,25 +64,30 @@ class Site(SiteInDBBase): members_count: int category_topic: Optional[Topic] = None - @validator("permission_type") - def get_permission_type(cls, v: Optional[str], values: Dict[str, Any]) -> str: + @model_validator(mode="after") + def compute_permission_type(self) -> Self: if ( - values["public_readable"] - and values["public_writable_question"] - and values["public_writable_answer"] - and values["public_writable_comment"] + self.public_readable + and self.public_writable_question + and self.public_writable_answer + and self.public_writable_comment ): - return "public" - if ( - (not values["public_readable"]) - and (not values["public_writable_question"]) - and (not values["public_writable_answer"]) - and (not values["public_writable_comment"]) + self.permission_type = "public" + elif ( + (not self.public_readable) + and (not self.public_writable_question) + and (not self.public_writable_answer) + and (not self.public_writable_comment) ): - return "private" - logger.error("site permission broken, name={},subdomain={}".format(values["name"], values["subdomain"])) - return "private" - #raise Exception(f"Incompatible site flags: {values}") + self.permission_type = "private" + else: + logger.error( + "site permission broken, name={},subdomain={}".format( + self.name, self.subdomain + ) + ) + self.permission_type = "private" + return self # Additional properties stored in DB diff --git a/chafan_core/app/schemas/submission.py b/chafan_core/app/schemas/submission.py index 1f0e772..da56427 100644 --- a/chafan_core/app/schemas/submission.py +++ b/chafan_core/app/schemas/submission.py @@ -1,7 +1,7 @@ import datetime from typing import List, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel from pydantic.networks import AnyHttpUrl from chafan_core.app.schemas.comment import Comment, CommentForVisitor @@ -9,7 +9,7 @@ from chafan_core.app.schemas.richtext import RichText from chafan_core.app.schemas.site import Site from chafan_core.app.schemas.topic import Topic -from chafan_core.utils.validators import StrippedNonEmptyStr, validate_submission_title +from chafan_core.utils.validators import StrippedNonEmptyStr, SubmissionTitle # Shared properties @@ -20,29 +20,16 @@ class SubmissionBase(BaseModel): # Properties to receive via API on creation class SubmissionCreate(SubmissionBase): site_uuid: str - title: StrippedNonEmptyStr + title: SubmissionTitle url: Optional[AnyHttpUrl] = None - @validator("title") - def _valid_title(cls, v: str) -> str: - validate_submission_title(v) - return v - # Properties to receive via API on update class SubmissionUpdate(SubmissionBase): - title: Optional[StrippedNonEmptyStr] = None + title: Optional[SubmissionTitle] = None desc: Optional[RichText] = None topic_uuids: Optional[List[str]] = None - @validator("title") - def _valid_title( - cls, v: Optional[StrippedNonEmptyStr] - ) -> Optional[StrippedNonEmptyStr]: - if v is not None: - validate_submission_title(v) - return v - class SubmissionInDB(SubmissionBase): uuid: str diff --git a/chafan_core/app/schemas/user.py b/chafan_core/app/schemas/user.py index c1ef76b..9302cb5 100644 --- a/chafan_core/app/schemas/user.py +++ b/chafan_core/app/schemas/user.py @@ -1,7 +1,7 @@ import datetime from typing import List, Literal, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel from pydantic.networks import AnyHttpUrl from pydantic.types import SecretStr @@ -17,7 +17,7 @@ CaseInsensitiveEmailStr, StrippedNonEmptyBasicStr, StrippedNonEmptyStr, - validate_password, + ValidPassword, ) @@ -31,14 +31,9 @@ class UserBase(BaseModel): # Properties to receive via API on creation class UserCreate(UserBase): email: CaseInsensitiveEmailStr - password: SecretStr + password: ValidPassword handle: Optional[StrippedNonEmptyBasicStr] - @validator("password") - def _valid_password(cls, v: SecretStr) -> SecretStr: - validate_password(v) - return v - class UserInvite(BaseModel): user_uuid: str @@ -67,15 +62,9 @@ class InviteIn(BaseModel): # Properties to receive via API on update class UserUpdate(UserBase): - password: Optional[SecretStr] = None + password: Optional[ValidPassword] = None handle: Optional[StrippedNonEmptyStr] = None - @validator("password") - def _valid_password(cls, v: Optional[SecretStr]) -> Optional[SecretStr]: - if v is not None: - validate_password(v) - return v - class UserWorkExperience(BaseModel): company_topic: Topic @@ -223,7 +212,7 @@ class UserUpdateMe(BaseModel): handle: Optional[str] = None about: Optional[str] = None default_editor_mode: Optional[editor_T] = None - password: Optional[SecretStr] = None + password: Optional[ValidPassword] = None residency_topic_uuids: Optional[List[str]] = None profession_topic_uuids: Optional[List[str]] = None education_experiences: Optional[List[UserEducationExperienceInternal]] = None @@ -238,9 +227,3 @@ class UserUpdateMe(BaseModel): avatar_url: Optional[AnyHttpUrl] = None gif_avatar_url: Optional[AnyHttpUrl] = None enable_deliver_unread_notifications: Optional[bool] = None - - @validator("password") - def _valid_password(cls, v: Optional[SecretStr]) -> Optional[SecretStr]: - if v is not None: - validate_password(v) - return v diff --git a/chafan_core/app/search.py b/chafan_core/app/search.py index 77bb900..b2b9add 100644 --- a/chafan_core/app/search.py +++ b/chafan_core/app/search.py @@ -1,3 +1,4 @@ +import logging import os from typing import List, Mapping, Optional @@ -11,7 +12,6 @@ from chafan_core.app.config import settings from chafan_core.utils.constants import indexed_object_T -import logging logger = logging.getLogger(__name__) _analyzer = ChineseAnalyzer() diff --git a/chafan_core/app/security.py b/chafan_core/app/security.py index c7f92b8..4593f61 100644 --- a/chafan_core/app/security.py +++ b/chafan_core/app/security.py @@ -1,24 +1,17 @@ import datetime -from typing import Any, Optional, Union - +import logging import random +from typing import Any, Optional, Union from jose import jwt from passlib.context import CryptContext # type: ignore from pydantic.types import SecretStr -from chafan_core.utils.validators import CaseInsensitiveEmailStr +from chafan_core.app.common import check_email, client_ip, get_redis_cli, is_dev from chafan_core.app.config import settings from chafan_core.utils.base import unwrap +from chafan_core.utils.validators import CaseInsensitiveEmailStr -from chafan_core.app.common import ( - check_email, - client_ip, - get_redis_cli, - is_dev, -) - -import logging logger = logging.getLogger(__name__) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -27,11 +20,11 @@ ALGORITHM = "HS256" -async def check_digit_verification_code(email:str, code:str) -> bool: - #logger.info("Check verification code") +async def check_digit_verification_code(email: str, code: str) -> bool: + # logger.info("Check verification code") bypass = settings.DEBUG_BYPASS_REDIS_VERIFICATION_CODE if bypass is not None and bypass.startswith("magic") and bypass == code: - logger.warning("Using magic bypass code for email="+email) + logger.warning("Using magic bypass code for email=" + email) return True redis_cli = get_redis_cli() key = f"chafan:verification-code:{email}" @@ -43,7 +36,8 @@ async def check_digit_verification_code(email:str, code:str) -> bool: redis_cli.delete(key) return True -async def register_digit_verification_code(email:str, code:str) -> None: + +async def register_digit_verification_code(email: str, code: str) -> None: redis_cli = get_redis_cli() key = f"chafan:verification-code:{email}" redis_cli.delete(key) @@ -51,13 +45,13 @@ async def register_digit_verification_code(email:str, code:str) -> None: redis_cli.expire( key, time=datetime.timedelta(hours=settings.EMAIL_SIGNUP_CODE_EXPIRE_HOURS) ) - #logger.info("Register verification code") + # logger.info("Register verification code") - -def create_digit_verification_code(length:int) -> str: +def create_digit_verification_code(length: int) -> str: return "".join([str(random.randint(0, 9)) for _ in range(length)]) + def create_access_token( subject: Union[str, Any], expires_delta: Optional[datetime.timedelta] = None ) -> str: @@ -110,4 +104,3 @@ def verify_password_reset_token(token: str) -> Optional[str]: return decoded_token["email"] except Exception: return None - diff --git a/chafan_core/app/task.py b/chafan_core/app/task.py index 104c91c..387ffe4 100644 --- a/chafan_core/app/task.py +++ b/chafan_core/app/task.py @@ -1,25 +1,23 @@ import datetime +import logging +from collections import Counter from typing import List, Optional -from collections import Counter import dramatiq from dramatiq.brokers.redis import RedisBroker from sqlalchemy.orm.session import Session - - +import chafan_core.app.rep_manager as rep from chafan_core.app import crud, models, schemas -from chafan_core.app.cached_layer import CachedLayer -from chafan_core.app.cached_layer import BUMP_VIEW_COUNT_QUEUE_CACHE_KEY +from chafan_core.app.cached_layer import BUMP_VIEW_COUNT_QUEUE_CACHE_KEY, CachedLayer from chafan_core.app.config import settings -from chafan_core.app.feed import ( - new_activity_into_feed, -) from chafan_core.app.crud.crud_activity import ( create_answer_activity, create_article_activity, ) from chafan_core.app.data_broker import DataBroker +from chafan_core.app.feed import new_activity_into_feed +from chafan_core.app.models.activity import Activity from chafan_core.app.recs.indexing import ( compute_interesting_questions_ids_for_normal_user, compute_interesting_questions_ids_for_visitor_user, @@ -59,11 +57,7 @@ ) from chafan_core.db.session import SessionLocal from chafan_core.utils.base import TaskStatus, get_utc_now -from chafan_core.app.models.activity import Activity -import chafan_core.app.rep_manager as rep - -import logging logger = logging.getLogger(__name__) @@ -74,7 +68,6 @@ dramatiq.set_broker(dramatiq_broker) - def notify_mentioned_users( broker: DataBroker, comment: models.Comment, user_handles: List[str] ) -> None: @@ -273,10 +266,10 @@ def runnable(broker: DataBroker) -> None: rep.new_question(question) question_ac = models.Activity( - created_at=utc_now, - site_id=question.site_id, - event_json=event_json, - ) + created_at=utc_now, + site_id=question.site_id, + event_json=event_json, + ) db = broker.get_db() db.add(question_ac) db.flush() @@ -455,7 +448,9 @@ def runnable(db: Session) -> None: def postprocess_new_answer(answer_id: int, was_published: bool) -> None: def runnable(broker: DataBroker) -> None: answer = crud.answer.get(broker.get_db(), id=answer_id) - logger.info(f"postprocess_new_answer id={answer_id}, was_published={was_published}") + logger.info( + f"postprocess_new_answer id={answer_id}, was_published={was_published}" + ) assert answer is not None and answer.is_published utc_now = datetime.datetime.now(tz=datetime.timezone.utc) if not was_published: @@ -471,10 +466,10 @@ def runnable(broker: DataBroker) -> None: receiver_id=answer.question.author.id, ) answer_ac: Activity = create_answer_activity( - answer=answer, - site_id=answer.question.site.id, - created_at=utc_now, - ) + answer=answer, + site_id=answer.question.site.id, + created_at=utc_now, + ) db = broker.get_db() db.add(answer_ac) db.flush() @@ -524,7 +519,9 @@ def runnable(broker: DataBroker) -> None: event=event_internal, receiver_id=subscriber.id, ) - article_ac: Activity = create_article_activity(article=article, created_at=utc_now) + article_ac: Activity = create_article_activity( + article=article, created_at=utc_now + ) db = broker.get_db() db.add(article_ac) db.flush() @@ -600,7 +597,13 @@ def runnable(db: Session) -> None: execute_with_db(SessionLocal(), runnable) -from chafan_core.app.models.viewcount import ViewCountQuestion, ViewCountAnswer, ViewCountArticle, ViewCountSubmission +from chafan_core.app.models.viewcount import ( + ViewCountAnswer, + ViewCountArticle, + ViewCountQuestion, + ViewCountSubmission, +) + # TODO Should I move this function to another file? 2025-07-23 def _add_viewcount_to_db(broker: DataBroker, key: str, count: int) -> None: @@ -611,7 +614,11 @@ def _add_viewcount_to_db(broker: DataBroker, key: str, count: int) -> None: db = broker.get_db() def bump_question(): - prev = db.query(ViewCountQuestion).filter(ViewCountQuestion.question_id == row_id).first() + prev = ( + db.query(ViewCountQuestion) + .filter(ViewCountQuestion.question_id == row_id) + .first() + ) if prev is None: prev = ViewCountQuestion() prev.question_id = row_id @@ -622,7 +629,11 @@ def bump_question(): db.commit() def bump_answer(): - prev = db.query(ViewCountAnswer).filter(ViewCountAnswer.answer_id == row_id).first() + prev = ( + db.query(ViewCountAnswer) + .filter(ViewCountAnswer.answer_id == row_id) + .first() + ) if prev is None: prev = ViewCountAnswer() prev.answer_id = row_id @@ -630,8 +641,13 @@ def bump_answer(): prev.view_count += count db.add(prev) db.commit() + def bump_article(): - prev = db.query(ViewCountArticle).filter(ViewCountArticle.article_id == row_id).first() + prev = ( + db.query(ViewCountArticle) + .filter(ViewCountArticle.article_id == row_id) + .first() + ) if prev is None: prev = ViewCountArticle() prev.article_id = row_id @@ -641,7 +657,11 @@ def bump_article(): db.commit() def bump_submission(): - prev = db.query(ViewCountSubmission).filter(ViewCountSubmission.submission_id == row_id).first() + prev = ( + db.query(ViewCountSubmission) + .filter(ViewCountSubmission.submission_id == row_id) + .first() + ) if prev is None: prev = ViewCountSubmission() prev.submission_id = row_id @@ -662,18 +682,19 @@ def bump_submission(): logger.error(f"Unhandled viewcount key: {key}") - - def write_view_count_to_db() -> None: def runnable(broker: DataBroker): logger.debug("write_view_count_to_db called") redis = broker.get_redis() views = redis.lrange(BUMP_VIEW_COUNT_QUEUE_CACHE_KEY, 0, -1) - redis.delete(BUMP_VIEW_COUNT_QUEUE_CACHE_KEY) # Race condition here. But losing a few view counts is okay + redis.delete( + BUMP_VIEW_COUNT_QUEUE_CACHE_KEY + ) # Race condition here. But losing a few view counts is okay view_dict = Counter(views) logger.debug("get views " + str(view_dict)) - for k,v in view_dict.items(): + for k, v in view_dict.items(): _add_viewcount_to_db(broker, k, v) + execute_with_broker(runnable) return None @@ -694,6 +715,7 @@ def runnable(broker: DataBroker): from chafan_core.db.session import ReadSessionLocal from chafan_core.utils.constants import indexed_object_T + @contextmanager def _index_rewriter(index_type: indexed_object_T) -> Iterator[writing.IndexWriter]: index_dir = settings.SEARCH_INDEX_FILESYSTEM_PATH + "/" + index_type diff --git a/chafan_core/app/task_utils.py b/chafan_core/app/task_utils.py index 008fb51..e361203 100644 --- a/chafan_core/app/task_utils.py +++ b/chafan_core/app/task_utils.py @@ -9,6 +9,7 @@ # TODO This file should be removed. These patterns provide little benefit today 2025-Jul-11 + def execute_with_db( db: Session, runnable: Callable[[Session], T], auto_commit: bool = True ) -> Optional[T]: diff --git a/chafan_core/app/user_permission.py b/chafan_core/app/user_permission.py index 5367978..1cd7a15 100644 --- a/chafan_core/app/user_permission.py +++ b/chafan_core/app/user_permission.py @@ -1,3 +1,4 @@ +import logging from typing import Optional from sqlalchemy.orm import Session @@ -7,8 +8,6 @@ from chafan_core.app.config import settings from chafan_core.utils.base import ContentVisibility - -import logging logger = logging.getLogger(__name__) # TODO everything about user permission, including if they can create a site (KARMA), invite a user, write an answer, etc, should be moved into this file. 2025-07-08 @@ -19,6 +18,7 @@ def get_active_site_profile( ) -> Optional[models.Profile]: return crud.profile.get_by_user_and_site(db, owner_id=user_id, site_id=site.id) + def user_in_site( db: Session, site: models.Site, @@ -41,8 +41,10 @@ def user_in_site( return False return True + def article_read_allowed( - db: Session, article: models.Article, user_id: Optional[int]) -> bool: + db: Session, article: models.Article, user_id: Optional[int] +) -> bool: if article.is_published and article.visibility == ContentVisibility.ANYONE: return True if article.author_id == user_id and user_id is not None: @@ -51,8 +53,10 @@ def article_read_allowed( logger.info(f"User {user_id} is not allowed to read article {article.id}") return False + def question_read_allowed( - cached_layer, question: models.Question, user_id: Optional[int]) -> bool: + cached_layer, question: models.Question, user_id: Optional[int] +) -> bool: if not question.is_hidden: return True if user_id is not None and user_id == question.author_id: @@ -61,5 +65,3 @@ def question_read_allowed( return False # TODO we should allow superuser and admin of sites to see hidden questions return False - - diff --git a/chafan_core/app/view_counters.py b/chafan_core/app/view_counters.py index 541bb79..7d5bd64 100644 --- a/chafan_core/app/view_counters.py +++ b/chafan_core/app/view_counters.py @@ -1,16 +1,15 @@ +import logging from typing import Literal -import logging logger = logging.getLogger(__name__) -#from chafan_core.app.cached_layer import CachedLayer - +# from chafan_core.app.cached_layer import CachedLayer async def add_view_async( - cached_layer, # TODO 2025-07-20 due to cyclic dep, turn off this type hint: CachedLayer, + cached_layer, # TODO 2025-07-20 due to cyclic dep, turn off this type hint: CachedLayer, object_type: Literal["question", "answer", "profile", "article", "submission"], - obj_id: int + obj_id: int, ) -> None: assert object_type in ["question", "answer", "article", "submission"] @@ -26,31 +25,54 @@ def add_view( return 0 -from chafan_core.app.models.viewcount import ViewCountQuestion, ViewCountAnswer, ViewCountArticle, ViewCountSubmission +from chafan_core.app.models.viewcount import ( + ViewCountAnswer, + ViewCountArticle, + ViewCountQuestion, + ViewCountSubmission, +) -def get_viewcount_question(broker, row_id:int)->int: +def get_viewcount_question(broker, row_id: int) -> int: db = broker.get_db() - row = db.query(ViewCountQuestion).filter(ViewCountQuestion.question_id == row_id).first() + row = ( + db.query(ViewCountQuestion) + .filter(ViewCountQuestion.question_id == row_id) + .first() + ) if row is None: return 0 return row.view_count -def get_viewcount_article(broker, row_id:int)->int: + + +def get_viewcount_article(broker, row_id: int) -> int: db = broker.get_db() - row = db.query(ViewCountArticle).filter(ViewCountArticle.article_id == row_id).first() + row = ( + db.query(ViewCountArticle).filter(ViewCountArticle.article_id == row_id).first() + ) if row is None: return 0 return row.view_count -def get_viewcount_submission(broker, row_id:int)->int: + + +def get_viewcount_submission(broker, row_id: int) -> int: db = broker.get_db() - row = db.query(ViewCountSubmission).filter(ViewCountSubmission.submission_id == row_id).first() + row = ( + db.query(ViewCountSubmission) + .filter(ViewCountSubmission.submission_id == row_id) + .first() + ) if row is None: return 0 return row.view_count -def get_viewcount_answer(broker, row_id:int)->int: + + +def get_viewcount_answer(broker, row_id: int) -> int: db = broker.get_db() row = db.query(ViewCountAnswer).filter(ViewCountAnswer.answer_id == row_id).first() if row is None: return 0 return row.view_count -#object_type: Literal["question", "answer", "profile", "article", "submission"], + + +# object_type: Literal["question", "answer", "profile", "article", "submission"], diff --git a/chafan_core/app/ws_connections.py b/chafan_core/app/ws_connections.py index dd09ce3..49151d5 100644 --- a/chafan_core/app/ws_connections.py +++ b/chafan_core/app/ws_connections.py @@ -1,10 +1,11 @@ +import logging from typing import MutableMapping from fastapi.websockets import WebSocket -import logging logger = logging.getLogger(__name__) + class ConnectionManager: def __init__(self) -> None: # User ID -> WebSocket @@ -26,4 +27,3 @@ async def send_message(self, message: str, user_id: int) -> None: manager = ConnectionManager() - diff --git a/chafan_core/db/init_db.py b/chafan_core/db/init_db.py index a6d4529..7465c44 100644 --- a/chafan_core/db/init_db.py +++ b/chafan_core/db/init_db.py @@ -1,4 +1,5 @@ import asyncio + from sqlalchemy.orm import Session from chafan_core.app import crud, schemas diff --git a/chafan_core/tests/app/api/api_v1/test_answers.py b/chafan_core/tests/app/api/api_v1/test_answers.py index c8ec9d7..a0aa7b4 100644 --- a/chafan_core/tests/app/api/api_v1/test_answers.py +++ b/chafan_core/tests/app/api/api_v1/test_answers.py @@ -94,7 +94,9 @@ def test_create_answer_success( assert db_answer.question.uuid == normal_user_authored_question_uuid -@pytest.mark.skip(reason="TODO: Test isolation issue - 'You have saved an answer before' error when using same question") +@pytest.mark.skip( + reason="TODO: Test isolation issue - 'You have saved an answer before' error when using same question" +) def test_create_answer_as_draft( client: TestClient, db: Session, @@ -164,7 +166,9 @@ def test_create_answer_invalid_question( # ============================================================================= -@pytest.mark.skip(reason="TODO: Test isolation issue - cannot create another answer to same question") +@pytest.mark.skip( + reason="TODO: Test isolation issue - cannot create another answer to same question" +) def test_get_answer_success( client: TestClient, db: Session, @@ -234,7 +238,9 @@ def test_get_answer_nonexistent( # ============================================================================= -@pytest.mark.skip(reason="TODO: Test isolation issue - cannot create another answer to same question") +@pytest.mark.skip( + reason="TODO: Test isolation issue - cannot create another answer to same question" +) def test_update_answer_as_author( client: TestClient, db: Session, @@ -303,7 +309,9 @@ def test_update_answer_as_author( assert archive.body == original_content -@pytest.mark.skip(reason="TODO: Test isolation issue - cannot create another answer to same question") +@pytest.mark.skip( + reason="TODO: Test isolation issue - cannot create another answer to same question" +) def test_update_answer_as_non_author( client: TestClient, db: Session, @@ -365,7 +373,9 @@ def test_update_answer_as_non_author( # ============================================================================= -@pytest.mark.skip(reason="TODO: Test isolation issue - cannot create another answer to same question") +@pytest.mark.skip( + reason="TODO: Test isolation issue - cannot create another answer to same question" +) def test_delete_answer_success( client: TestClient, db: Session, diff --git a/chafan_core/tests/app/api/api_v1/test_articles.py b/chafan_core/tests/app/api/api_v1/test_articles.py index 2fcedf6..3056f76 100644 --- a/chafan_core/tests/app/api/api_v1/test_articles.py +++ b/chafan_core/tests/app/api/api_v1/test_articles.py @@ -31,7 +31,9 @@ def test_get_article_unauthenticated( # Verify data exists in database db.expire_all() db_article = crud.article.get_by_uuid(db, uuid=example_article_uuid) - assert db_article is not None, f"Article {example_article_uuid} not found in database" + assert ( + db_article is not None + ), f"Article {example_article_uuid} not found in database" assert db_article.uuid == example_article_uuid assert db_article.title == data["title"] assert db_article.is_published is True @@ -60,12 +62,16 @@ def test_get_article_authenticated( # Verify data in database matches response db.expire_all() db_article = crud.article.get_by_uuid(db, uuid=example_article_uuid) - assert db_article is not None, f"Article {example_article_uuid} not found in database" + assert ( + db_article is not None + ), f"Article {example_article_uuid} not found in database" assert db_article.title == data["title"] assert db_article.body == data["content"]["source"] -@pytest.mark.skip(reason="TODO: get_article endpoint doesn't handle None article before accessing is_published") +@pytest.mark.skip( + reason="TODO: get_article endpoint doesn't handle None article before accessing is_published" +) def test_get_article_nonexistent( client: TestClient, db: Session, @@ -588,7 +594,9 @@ def test_get_article_archives_unauthorized( # Verify the article exists but access is denied (not a data issue) db.expire_all() db_article = crud.article.get_by_uuid(db, uuid=example_article_uuid) - assert db_article is not None, f"Article {example_article_uuid} not found in database" + assert ( + db_article is not None + ), f"Article {example_article_uuid} not found in database" # ============================================================================= @@ -655,7 +663,9 @@ def test_delete_article_unauthorized( # Verify article exists and is not deleted db.expire_all() db_article_before = crud.article.get_by_uuid(db, uuid=example_article_uuid) - assert db_article_before is not None, f"Article {example_article_uuid} not found in database" + assert ( + db_article_before is not None + ), f"Article {example_article_uuid} not found in database" assert db_article_before.is_deleted is False r = client.delete( diff --git a/chafan_core/tests/app/api/api_v1/test_comments.py b/chafan_core/tests/app/api/api_v1/test_comments.py index 39b5544..5be60de 100644 --- a/chafan_core/tests/app/api/api_v1/test_comments.py +++ b/chafan_core/tests/app/api/api_v1/test_comments.py @@ -187,7 +187,9 @@ def test_get_comment_nonexistent( # ============================================================================= -@pytest.mark.skip(reason="TODO: Comment update API uses different payload format (content RichText, not body string)") +@pytest.mark.skip( + reason="TODO: Comment update API uses different payload format (content RichText, not body string)" +) def test_update_comment_as_author( client: TestClient, db: Session, diff --git a/chafan_core/tests/app/api/api_v1/test_questions.py b/chafan_core/tests/app/api/api_v1/test_questions.py index 3529ee9..6c53e71 100644 --- a/chafan_core/tests/app/api/api_v1/test_questions.py +++ b/chafan_core/tests/app/api/api_v1/test_questions.py @@ -87,8 +87,12 @@ def test_create_question_success( """Test successful question creation and verify data in PostgreSQL.""" # Ensure user is a site member ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) title = f"Test Question {random_lower_string()}" @@ -144,8 +148,12 @@ def test_get_question_success( """Test getting a question and verify data matches PostgreSQL.""" # Ensure user is a site member ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) # First create a question @@ -182,7 +190,9 @@ def test_get_question_success( assert db_question.uuid == response_data["uuid"] -@pytest.mark.skip(reason="TODO: get_question endpoint doesn't handle None question before accessing is_hidden") +@pytest.mark.skip( + reason="TODO: get_question endpoint doesn't handle None question before accessing is_hidden" +) def test_get_question_nonexistent( client: TestClient, db: Session, @@ -205,7 +215,9 @@ def test_get_question_nonexistent( # ============================================================================= -@pytest.mark.skip(reason="TODO: QuestionCreate doesn't support description, QuestionUpdate uses 'desc' (RichText) not 'description'") +@pytest.mark.skip( + reason="TODO: QuestionCreate doesn't support description, QuestionUpdate uses 'desc' (RichText) not 'description'" +) def test_update_question_as_author( client: TestClient, db: Session, @@ -218,8 +230,12 @@ def test_update_question_as_author( """Test updating a question as author and verify in PostgreSQL.""" # Ensure user is a site member ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) # First create a question @@ -260,7 +276,9 @@ def test_update_question_as_author( assert new_description in db_question_after.description -@pytest.mark.skip(reason="TODO: QuestionCreate doesn't support description, QuestionUpdate uses 'desc' (RichText) not 'description'") +@pytest.mark.skip( + reason="TODO: QuestionCreate doesn't support description, QuestionUpdate uses 'desc' (RichText) not 'description'" +) def test_update_question_as_non_author( client: TestClient, db: Session, @@ -274,8 +292,12 @@ def test_update_question_as_non_author( """Test that non-authors cannot update questions.""" # Ensure user is a site member ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) # Create a question as normal user @@ -345,8 +367,12 @@ def test_bump_question_views( """Test bumping views counter for a question.""" # Ensure user is a site member ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) # Create a question @@ -404,8 +430,12 @@ def test_get_question_upvotes( """Test getting upvotes for a question.""" # Ensure user is a site member ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) # Create a question diff --git a/chafan_core/tests/app/api/api_v1/test_sites.py b/chafan_core/tests/app/api/api_v1/test_sites.py index dc0c5fc..9dfc2b2 100644 --- a/chafan_core/tests/app/api/api_v1/test_sites.py +++ b/chafan_core/tests/app/api/api_v1/test_sites.py @@ -55,7 +55,9 @@ def test_sites( # Verify the site was updated by fetching it directly # Use superuser headers since they created the site and have a site profile - r = client.get(f"{settings.API_V1_STR}/sites/demo_{demo_name}", headers=superuser_token_headers) + r = client.get( + f"{settings.API_V1_STR}/sites/demo_{demo_name}", headers=superuser_token_headers + ) assert r.status_code == 200, (r.status_code, r.json()) site = r.json() assert site["description"] == "Demo Site 2" diff --git a/chafan_core/tests/app/api/api_v1/test_submissions.py b/chafan_core/tests/app/api/api_v1/test_submissions.py index 13ee6ef..b1f75a6 100644 --- a/chafan_core/tests/app/api/api_v1/test_submissions.py +++ b/chafan_core/tests/app/api/api_v1/test_submissions.py @@ -64,9 +64,7 @@ def test_get_submission_upvotes_nonexistent( db: Session, ) -> None: """Test getting upvotes for a nonexistent submission returns an error.""" - r = client.get( - f"{settings.API_V1_STR}/submissions/invalid-uuid/upvotes/" - ) + r = client.get(f"{settings.API_V1_STR}/submissions/invalid-uuid/upvotes/") assert r.status_code == 400 assert "doesn't exists" in r.json()["detail"] @@ -102,9 +100,7 @@ def test_bump_views_counter_nonexistent( db: Session, ) -> None: """Test bumping views for nonexistent submission returns an error.""" - r = client.post( - f"{settings.API_V1_STR}/submissions/invalid-uuid/views/" - ) + r = client.post(f"{settings.API_V1_STR}/submissions/invalid-uuid/views/") assert r.status_code == 400 # Verify it doesn't exist in database @@ -217,8 +213,12 @@ def test_create_submission_success( ) -> None: """Test successful submission creation and verify data in PostgreSQL.""" ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) title = f"Test Submission {random_lower_string()}" @@ -270,7 +270,9 @@ def test_update_submission_as_author( # Get original title from database db.expire_all() db_submission_before = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_before is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_before is not None + ), f"Submission {example_submission_uuid} not found" original_title = db_submission_before.title new_title = f"Updated Title {random_lower_string()}" @@ -303,7 +305,9 @@ def test_update_submission_as_non_author( # Get original title from database db.expire_all() db_submission_before = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_before is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_before is not None + ), f"Submission {example_submission_uuid} not found" original_title = db_submission_before.title data = { @@ -320,7 +324,9 @@ def test_update_submission_as_non_author( # Verify data was NOT changed in PostgreSQL db.expire_all() db_submission_after = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_after is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_after is not None + ), f"Submission {example_submission_uuid} not found" assert db_submission_after.title == original_title @@ -424,15 +430,21 @@ def test_upvote_submission_success( ) -> None: """Test upvoting a submission and verify in PostgreSQL.""" ensure_user_in_site( - client, db, moderator_user_id, moderator_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + moderator_user_id, + moderator_user_uuid, + example_site_uuid, + superuser_token_headers, ) ensure_user_has_coins(db, moderator_user_id, coins=100) # Get initial upvote count from database db.expire_all() db_submission_before = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_before is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_before is not None + ), f"Submission {example_submission_uuid} not found" initial_db_count = db_submission_before.upvotes_count r = client.get( @@ -453,7 +465,9 @@ def test_upvote_submission_success( # Verify upvote is recorded in PostgreSQL db.expire_all() db_submission_after = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_after is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_after is not None + ), f"Submission {example_submission_uuid} not found" assert db_submission_after.upvotes_count >= initial_db_count @@ -469,8 +483,12 @@ def test_upvote_submission_idempotent( ) -> None: """Test that double upvoting doesn't increase count.""" ensure_user_in_site( - client, db, moderator_user_id, moderator_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + moderator_user_id, + moderator_user_uuid, + example_site_uuid, + superuser_token_headers, ) ensure_user_has_coins(db, moderator_user_id, coins=100) @@ -484,7 +502,9 @@ def test_upvote_submission_idempotent( # Get database count after first upvote db.expire_all() db_submission_1 = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_1 is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_1 is not None + ), f"Submission {example_submission_uuid} not found" db_count1 = db_submission_1.upvotes_count # Second upvote (should not increase count) @@ -499,7 +519,9 @@ def test_upvote_submission_idempotent( # Verify database count unchanged db.expire_all() db_submission_2 = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_2 is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_2 is not None + ), f"Submission {example_submission_uuid} not found" assert db_submission_2.upvotes_count == db_count1 @@ -513,7 +535,9 @@ def test_upvote_submission_author_cannot_upvote( # Get initial count from database db.expire_all() db_submission_before = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_before is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_before is not None + ), f"Submission {example_submission_uuid} not found" initial_count = db_submission_before.upvotes_count r = client.post( @@ -526,7 +550,9 @@ def test_upvote_submission_author_cannot_upvote( # Verify count unchanged in database db.expire_all() db_submission_after = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_after is not None, f"Submission {example_submission_uuid} not found" + assert ( + db_submission_after is not None + ), f"Submission {example_submission_uuid} not found" assert db_submission_after.upvotes_count == initial_count @@ -542,8 +568,12 @@ def test_cancel_upvote_submission( ) -> None: """Test canceling an upvote and verify in PostgreSQL.""" ensure_user_in_site( - client, db, moderator_user_id, moderator_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + moderator_user_id, + moderator_user_uuid, + example_site_uuid, + superuser_token_headers, ) ensure_user_has_coins(db, moderator_user_id, coins=100) @@ -557,8 +587,12 @@ def test_cancel_upvote_submission( # Get database count after upvote db.expire_all() - db_submission_upvoted = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_upvoted is not None, f"Submission {example_submission_uuid} not found" + db_submission_upvoted = crud.submission.get_by_uuid( + db, uuid=example_submission_uuid + ) + assert ( + db_submission_upvoted is not None + ), f"Submission {example_submission_uuid} not found" db_count_upvoted = db_submission_upvoted.upvotes_count # Cancel upvote @@ -573,8 +607,12 @@ def test_cancel_upvote_submission( # Verify count decreased in database db.expire_all() - db_submission_cancelled = crud.submission.get_by_uuid(db, uuid=example_submission_uuid) - assert db_submission_cancelled is not None, f"Submission {example_submission_uuid} not found" + db_submission_cancelled = crud.submission.get_by_uuid( + db, uuid=example_submission_uuid + ) + assert ( + db_submission_cancelled is not None + ), f"Submission {example_submission_uuid} not found" assert db_submission_cancelled.upvotes_count <= db_count_upvoted @@ -594,8 +632,12 @@ def test_hide_submission_as_author( ) -> None: """Test hiding a submission and verify in PostgreSQL.""" ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) # Create a submission to hide diff --git a/chafan_core/tests/app/crud/a/test_crud_activity.py b/chafan_core/tests/app/crud/a/test_crud_activity.py index 6083f2e..46baec8 100644 --- a/chafan_core/tests/app/crud/a/test_crud_activity.py +++ b/chafan_core/tests/app/crud/a/test_crud_activity.py @@ -69,7 +69,9 @@ def _create_test_submission(db: Session, author_id: int, site): title=f"Test Submission {random_short_lower_string()}", url="https://example.com/test", ) - return crud.submission.create_with_author(db, obj_in=submission_in, author_id=author_id) + return crud.submission.create_with_author( + db, obj_in=submission_in, author_id=author_id + ) def _create_test_answer(db: Session, author_id: int, question, site): @@ -96,7 +98,9 @@ def _create_test_article_column(db: Session, owner_id: int): name=f"Test Column {random_short_lower_string()}", description="Test column description", ) - return crud.article_column.create_with_owner(db, obj_in=column_in, owner_id=owner_id) + return crud.article_column.create_with_owner( + db, obj_in=column_in, owner_id=owner_id + ) def _create_test_article(db: Session, author_id: int, column): @@ -138,7 +142,9 @@ def test_get_multi_by_id_range_with_max_id(db: Session) -> None: # Test with max_id max_id = all_activities[0].id + 1 min_id = all_activities[-1].id - 1 - range_activities = list(activity.get_multi_by_id_range(db, min_id=min_id, max_id=max_id)) + range_activities = list( + activity.get_multi_by_id_range(db, min_id=min_id, max_id=max_id) + ) assert len(range_activities) <= len(all_activities) diff --git a/chafan_core/tests/app/crud/a/test_crud_answer_suggest_edit.py b/chafan_core/tests/app/crud/a/test_crud_answer_suggest_edit.py index 852b563..090a656 100644 --- a/chafan_core/tests/app/crud/a/test_crud_answer_suggest_edit.py +++ b/chafan_core/tests/app/crud/a/test_crud_answer_suggest_edit.py @@ -77,7 +77,9 @@ def test_create_answer_suggest_edit_with_author(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edit_in = AnswerSuggestEditCreate( answer_uuid=answer.uuid, @@ -109,7 +111,9 @@ def test_create_answer_suggest_edit_without_comment(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edit_in = AnswerSuggestEditCreate( answer_uuid=answer.uuid, @@ -136,7 +140,9 @@ def test_get_answer_suggest_edit_by_id(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edit_in = AnswerSuggestEditCreate( answer_uuid=answer.uuid, @@ -163,7 +169,9 @@ def test_get_answer_suggest_edit_by_uuid(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edit_in = AnswerSuggestEditCreate( answer_uuid=answer.uuid, @@ -196,7 +204,9 @@ def test_update_answer_suggest_edit_status_to_accepted(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edit_in = AnswerSuggestEditCreate( answer_uuid=answer.uuid, @@ -227,7 +237,9 @@ def test_update_answer_suggest_edit_status_to_rejected(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edit_in = AnswerSuggestEditCreate( answer_uuid=answer.uuid, @@ -256,7 +268,9 @@ def test_update_answer_suggest_edit_status_to_retracted(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edit_in = AnswerSuggestEditCreate( answer_uuid=answer.uuid, @@ -285,7 +299,9 @@ def test_answer_suggest_edit_timestamps(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) before_create = datetime.datetime.now(tz=datetime.timezone.utc) @@ -314,7 +330,9 @@ def test_multiple_suggest_edits_for_same_answer(db: Session) -> None: answer_author = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edits = [] for i in range(3): @@ -344,7 +362,9 @@ def test_same_user_multiple_suggest_edits(db: Session) -> None: suggester = _create_test_user(db) site = _create_test_site(db, moderator=moderator) question = _create_test_question(db, author_id=answer_author.id, site=site) - answer = _create_test_answer(db, author_id=answer_author.id, question=question, site=site) + answer = _create_test_answer( + db, author_id=answer_author.id, question=question, site=site + ) suggest_edits = [] for i in range(2): diff --git a/chafan_core/tests/app/crud/a/test_crud_application.py b/chafan_core/tests/app/crud/a/test_crud_application.py index ec2fb88..4ee9e4f 100644 --- a/chafan_core/tests/app/crud/a/test_crud_application.py +++ b/chafan_core/tests/app/crud/a/test_crud_application.py @@ -224,8 +224,12 @@ def test_applications_to_different_sites(db: Session) -> None: db, create_in=application_in, applicant_id=applicant.id ) - app1 = crud.application.get_by_applicant_and_site(db, applicant=applicant, site=site1) - app2 = crud.application.get_by_applicant_and_site(db, applicant=applicant, site=site2) + app1 = crud.application.get_by_applicant_and_site( + db, applicant=applicant, site=site1 + ) + app2 = crud.application.get_by_applicant_and_site( + db, applicant=applicant, site=site2 + ) assert app1 is not None assert app2 is not None diff --git a/chafan_core/tests/app/crud/a/test_crud_article.py b/chafan_core/tests/app/crud/a/test_crud_article.py index 3f19b6d..46def8f 100644 --- a/chafan_core/tests/app/crud/a/test_crud_article.py +++ b/chafan_core/tests/app/crud/a/test_crud_article.py @@ -30,7 +30,9 @@ def _create_test_article_column(db: Session, owner_id: int): name=f"Test Column {random_short_lower_string()}", description="Test column description", ) - return crud.article_column.create_with_owner(db, obj_in=column_in, owner_id=owner_id) + return crud.article_column.create_with_owner( + db, obj_in=column_in, owner_id=owner_id + ) def test_create_article_with_author(db: Session) -> None: @@ -51,9 +53,7 @@ def test_create_article_with_author(db: Session) -> None: visibility=ContentVisibility.ANYONE, ) - article = crud.article.create_with_author( - db, obj_in=article_in, author_id=user.id - ) + article = crud.article.create_with_author(db, obj_in=article_in, author_id=user.id) assert article is not None assert article.author_id == user.id @@ -83,9 +83,7 @@ def test_create_unpublished_article(db: Session) -> None: visibility=ContentVisibility.ANYONE, ) - article = crud.article.create_with_author( - db, obj_in=article_in, author_id=user.id - ) + article = crud.article.create_with_author(db, obj_in=article_in, author_id=user.id) assert article is not None assert article.is_published is False @@ -109,9 +107,7 @@ def test_get_article_by_uuid(db: Session) -> None: visibility=ContentVisibility.ANYONE, ) - article = crud.article.create_with_author( - db, obj_in=article_in, author_id=user.id - ) + article = crud.article.create_with_author(db, obj_in=article_in, author_id=user.id) retrieved_article = crud.article.get_by_uuid(db, uuid=article.uuid) assert retrieved_article is not None @@ -243,9 +239,7 @@ def test_update_article_topics(db: Session) -> None: visibility=ContentVisibility.ANYONE, ) - article = crud.article.create_with_author( - db, obj_in=article_in, author_id=user.id - ) + article = crud.article.create_with_author(db, obj_in=article_in, author_id=user.id) # Create test topics from chafan_core.app.schemas.topic import TopicCreate @@ -285,9 +279,7 @@ def test_delete_forever(db: Session) -> None: visibility=ContentVisibility.ANYONE, ) - article = crud.article.create_with_author( - db, obj_in=article_in, author_id=user.id - ) + article = crud.article.create_with_author(db, obj_in=article_in, author_id=user.id) crud.article.delete_forever(db, article=article) @@ -316,9 +308,7 @@ def test_update_checked_cannot_unpublish(db: Session) -> None: visibility=ContentVisibility.ANYONE, ) - article = crud.article.create_with_author( - db, obj_in=article_in, author_id=user.id - ) + article = crud.article.create_with_author(db, obj_in=article_in, author_id=user.id) # Should raise assertion error when trying to unpublish try: @@ -396,9 +386,7 @@ def test_get_all(db: Session) -> None: visibility=ContentVisibility.ANYONE, ) - crud.article.create_with_author( - db, obj_in=article_in, author_id=user.id - ) + crud.article.create_with_author(db, obj_in=article_in, author_id=user.id) all_articles = crud.article.get_all(db) assert len(all_articles) == initial_count + 1 diff --git a/chafan_core/tests/app/crud/a/test_crud_article_column.py b/chafan_core/tests/app/crud/a/test_crud_article_column.py index 746758e..8f56efd 100644 --- a/chafan_core/tests/app/crud/a/test_crud_article_column.py +++ b/chafan_core/tests/app/crud/a/test_crud_article_column.py @@ -2,7 +2,10 @@ from sqlalchemy.orm import Session from chafan_core.app import crud -from chafan_core.app.schemas.article_column import ArticleColumnCreate, ArticleColumnUpdate +from chafan_core.app.schemas.article_column import ( + ArticleColumnCreate, + ArticleColumnUpdate, +) from chafan_core.app.schemas.user import UserCreate from chafan_core.tests.utils.utils import ( random_email, @@ -137,7 +140,9 @@ def test_update_article_column_partial(db: Session) -> None: # Update only description new_description = "New description only" - crud.article_column.update(db, db_obj=column, obj_in={"description": new_description}) + crud.article_column.update( + db, db_obj=column, obj_in={"description": new_description} + ) db.refresh(column) assert column.name == original_name # Name unchanged @@ -219,9 +224,7 @@ def test_get_all_article_columns(db: Session) -> None: description="Test column", ) - crud.article_column.create_with_owner( - db, obj_in=column_in, owner_id=user.id - ) + crud.article_column.create_with_owner(db, obj_in=column_in, owner_id=user.id) all_columns = crud.article_column.get_all(db) assert len(all_columns) == initial_count + 1 diff --git a/chafan_core/tests/app/crud/a/test_crud_audit_log.py b/chafan_core/tests/app/crud/a/test_crud_audit_log.py index e7e9a45..d78a730 100644 --- a/chafan_core/tests/app/crud/a/test_crud_audit_log.py +++ b/chafan_core/tests/app/crud/a/test_crud_audit_log.py @@ -135,7 +135,11 @@ def test_audit_log_with_request_info(db: Session) -> None: request_info = {"user_agent": "Mozilla/5.0", "method": "POST"} audit_log = _create_audit_log_directly( - db, user_id=user.id, ipaddr="192.168.1.1", api="action", request_info=request_info + db, + user_id=user.id, + ipaddr="192.168.1.1", + api="action", + request_info=request_info, ) retrieved = crud.audit_log.get(db, id=audit_log.id) @@ -183,9 +187,7 @@ def test_multiple_audit_logs_same_ip(db: Session) -> None: logs = [] for _ in range(3): - log = _create_audit_log_directly( - db, user_id=user.id, ipaddr=ip, api="action" - ) + log = _create_audit_log_directly(db, user_id=user.id, ipaddr=ip, api="action") logs.append(log) assert len(logs) == 3 diff --git a/chafan_core/tests/app/crud/a/test_crud_coin_payment.py b/chafan_core/tests/app/crud/a/test_crud_coin_payment.py index 54a0c68..3898b03 100644 --- a/chafan_core/tests/app/crud/a/test_crud_coin_payment.py +++ b/chafan_core/tests/app/crud/a/test_crud_coin_payment.py @@ -106,7 +106,9 @@ def test_get_with_event_json_and_payee_id(db: Session) -> None: assert retrieved_payment.id == created_payment.id -def test_get_with_event_json_and_payee_id_returns_none_when_not_found(db: Session) -> None: +def test_get_with_event_json_and_payee_id_returns_none_when_not_found( + db: Session, +) -> None: """Test that get_with_event_json_and_payee_id returns None when not found.""" result = crud.coin_payment.get_with_event_json_and_payee_id( db, event_json='{"nonexistent": true}', payee_id=99999 diff --git a/chafan_core/tests/app/crud/a/test_crud_feedback.py b/chafan_core/tests/app/crud/a/test_crud_feedback.py index d120683..062455e 100644 --- a/chafan_core/tests/app/crud/a/test_crud_feedback.py +++ b/chafan_core/tests/app/crud/a/test_crud_feedback.py @@ -66,7 +66,10 @@ def test_feedback_with_different_statuses(db: Session) -> None: for status in statuses: feedback = _create_feedback_directly( - db, description=f"Feedback with status {status}", status=status, user_id=user.id + db, + description=f"Feedback with status {status}", + status=status, + user_id=user.id, ) retrieved = crud.feedback.get(db, id=feedback.id) @@ -98,9 +101,7 @@ def test_get_multi_feedback(db: Session) -> None: # Create several feedback entries for i in range(5): - _create_feedback_directly( - db, description=f"Feedback {i}", user_id=user.id - ) + _create_feedback_directly(db, description=f"Feedback {i}", user_id=user.id) feedbacks = crud.feedback.get_multi(db, skip=0, limit=10) assert len(feedbacks) >= 5 diff --git a/chafan_core/tests/app/crud/a/test_crud_form_response.py b/chafan_core/tests/app/crud/a/test_crud_form_response.py index 2393c49..97c290b 100644 --- a/chafan_core/tests/app/crud/a/test_crud_form_response.py +++ b/chafan_core/tests/app/crud/a/test_crud_form_response.py @@ -3,7 +3,12 @@ from sqlalchemy.orm import Session from chafan_core.app import crud -from chafan_core.app.schemas.form import FormCreate, FormField, TextField, SingleChoiceField +from chafan_core.app.schemas.form import ( + FormCreate, + FormField, + TextField, + SingleChoiceField, +) from chafan_core.app.schemas.form_response import ( FormResponseCreate, FormResponseField, diff --git a/chafan_core/tests/app/crud/b/test_crud_notification.py b/chafan_core/tests/app/crud/b/test_crud_notification.py index d3e437f..4b49246 100644 --- a/chafan_core/tests/app/crud/b/test_crud_notification.py +++ b/chafan_core/tests/app/crud/b/test_crud_notification.py @@ -22,7 +22,9 @@ def _create_test_user(db: Session): return asyncio.run(crud.user.create(db, obj_in=user_in)) -def _create_test_notification(db: Session, receiver_id: int, is_read: bool = False, is_delivered: bool = False): +def _create_test_notification( + db: Session, receiver_id: int, is_read: bool = False, is_delivered: bool = False +): """Helper to create a test notification.""" notification_in = NotificationCreate( receiver_id=receiver_id, @@ -102,7 +104,9 @@ def test_get_read_notifications(db: Session) -> None: read2 = _create_test_notification(db, receiver_id=user.id, is_read=True) # Create an unread notification - unread_notification = _create_test_notification(db, receiver_id=user.id, is_read=False) + unread_notification = _create_test_notification( + db, receiver_id=user.id, is_read=False + ) read_notifications = crud.notification.get_read(db, receiver_id=user.id) read_ids = [n.id for n in read_notifications] @@ -131,7 +135,9 @@ def test_get_undelivered_unread(db: Session) -> None: db, receiver_id=user.id, is_read=True, is_delivered=False ) - undelivered_unread_notifications = list(crud.notification.get_undelivered_unread(db)) + undelivered_unread_notifications = list( + crud.notification.get_undelivered_unread(db) + ) ids = [n.id for n in undelivered_unread_notifications] assert undelivered_unread.id in ids @@ -159,10 +165,14 @@ def test_get_unread_for_different_users(db: Session) -> None: user2 = _create_test_user(db) # Create notifications for user1 - user1_notification = _create_test_notification(db, receiver_id=user1.id, is_read=False) + user1_notification = _create_test_notification( + db, receiver_id=user1.id, is_read=False + ) # Create notifications for user2 - user2_notification = _create_test_notification(db, receiver_id=user2.id, is_read=False) + user2_notification = _create_test_notification( + db, receiver_id=user2.id, is_read=False + ) # Get unread for user1 user1_unread = crud.notification.get_unread(db, receiver_id=user1.id) @@ -193,7 +203,8 @@ def test_unread_ordered_by_created_at_desc(db: Session) -> None: # Create notifications with different timestamps older_notification_in = NotificationCreate( receiver_id=user.id, - created_at=datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(hours=1), + created_at=datetime.datetime.now(tz=datetime.timezone.utc) + - datetime.timedelta(hours=1), event_json='{"type": "older", "data": {}}', ) older_notification = crud.notification.create(db, obj_in=older_notification_in) @@ -219,7 +230,9 @@ def test_unread_ordered_by_created_at_desc(db: Session) -> None: def test_update_notification_mark_as_delivered(db: Session) -> None: """Test marking a notification as delivered.""" user = _create_test_user(db) - notification = _create_test_notification(db, receiver_id=user.id, is_delivered=False) + notification = _create_test_notification( + db, receiver_id=user.id, is_delivered=False + ) assert notification.is_delivered is False diff --git a/chafan_core/tests/app/crud/b/test_crud_profile.py b/chafan_core/tests/app/crud/b/test_crud_profile.py index 5f0ac6d..3cd8771 100644 --- a/chafan_core/tests/app/crud/b/test_crud_profile.py +++ b/chafan_core/tests/app/crud/b/test_crud_profile.py @@ -127,9 +127,7 @@ def test_remove_profile_returns_none_for_nonexistent(db: Session) -> None: site = _create_test_site(db, moderator) # Don't create a profile, just try to remove it - result = crud.profile.remove_by_user_and_site( - db, owner_id=user.id, site_id=site.id - ) + result = crud.profile.remove_by_user_and_site(db, owner_id=user.id, site_id=site.id) assert result is None @@ -270,7 +268,10 @@ def test_remove_only_removes_specific_profile(db: Session) -> None: crud.profile.remove_by_user_and_site(db, owner_id=user.id, site_id=site1.id) # Profile in site1 should be gone - assert crud.profile.get_by_user_and_site(db, owner_id=user.id, site_id=site1.id) is None + assert ( + crud.profile.get_by_user_and_site(db, owner_id=user.id, site_id=site1.id) + is None + ) # Profile in site2 should still exist profile2_retrieved = crud.profile.get_by_user_and_site( diff --git a/chafan_core/tests/app/crud/b/test_crud_report.py b/chafan_core/tests/app/crud/b/test_crud_report.py index a2796bf..2c9bc61 100644 --- a/chafan_core/tests/app/crud/b/test_crud_report.py +++ b/chafan_core/tests/app/crud/b/test_crud_report.py @@ -57,7 +57,9 @@ def _create_test_submission(db: Session, author_id: int, site): title=f"Test Submission {random_short_lower_string()}", url="https://example.com/test", ) - return crud.submission.create_with_author(db, obj_in=submission_in, author_id=author_id) + return crud.submission.create_with_author( + db, obj_in=submission_in, author_id=author_id + ) def _create_test_answer(db: Session, author_id: int, question, site): diff --git a/chafan_core/tests/app/crud/b/test_crud_site.py b/chafan_core/tests/app/crud/b/test_crud_site.py index 3b97702..c69cb28 100644 --- a/chafan_core/tests/app/crud/b/test_crud_site.py +++ b/chafan_core/tests/app/crud/b/test_crud_site.py @@ -299,13 +299,19 @@ def test_get_multi_submissions(db: Session) -> None: title=f"Submission {i} {random_short_lower_string()}", url=f"https://example.com/submission-{i}", ) - crud.submission.create_with_author(db, obj_in=submission_in, author_id=moderator.id) + crud.submission.create_with_author( + db, obj_in=submission_in, author_id=moderator.id + ) # Test pagination - submissions_page1 = crud.site.get_multi_submissions(db, db_obj=site, skip=0, limit=2) + submissions_page1 = crud.site.get_multi_submissions( + db, db_obj=site, skip=0, limit=2 + ) assert len(submissions_page1) == 2 - submissions_page2 = crud.site.get_multi_submissions(db, db_obj=site, skip=2, limit=2) + submissions_page2 = crud.site.get_multi_submissions( + db, db_obj=site, skip=2, limit=2 + ) assert len(submissions_page2) == 2 submissions_all = crud.site.get_multi_submissions(db, db_obj=site, skip=0, limit=10) @@ -347,7 +353,9 @@ def test_get_all_with_category_topic_ids(db: Session) -> None: db, obj_in=other_site_in, moderator=moderator, category_topic_id=None ) - sites_with_category = crud.site.get_all_with_category_topic_ids(db, category_topic_id=topic.id) + sites_with_category = crud.site.get_all_with_category_topic_ids( + db, category_topic_id=topic.id + ) site_ids = [s.id for s in sites_with_category] assert site.id in site_ids diff --git a/chafan_core/tests/app/crud/b/test_crud_submission.py b/chafan_core/tests/app/crud/b/test_crud_submission.py index e8fa442..c0710ea 100644 --- a/chafan_core/tests/app/crud/b/test_crud_submission.py +++ b/chafan_core/tests/app/crud/b/test_crud_submission.py @@ -241,7 +241,9 @@ def test_update_submission_topics_clear(db: Session) -> None: topic_in = TopicCreate(name=f"SubmissionTopic {random_short_lower_string()}") topic = crud.topic.create(db, obj_in=topic_in) - submission = crud.submission.update_topics(db, db_obj=submission, new_topics=[topic]) + submission = crud.submission.update_topics( + db, db_obj=submission, new_topics=[topic] + ) assert len(submission.topics) == 1 # Clear topics diff --git a/chafan_core/tests/app/crud/b/test_crud_submission_suggestion.py b/chafan_core/tests/app/crud/b/test_crud_submission_suggestion.py index cdaf256..75d2b0b 100644 --- a/chafan_core/tests/app/crud/b/test_crud_submission_suggestion.py +++ b/chafan_core/tests/app/crud/b/test_crud_submission_suggestion.py @@ -48,7 +48,9 @@ def _create_test_submission(db: Session, author_id: int, site): title=f"Test Submission {random_short_lower_string()}", url="https://example.com/test", ) - return crud.submission.create_with_author(db, obj_in=submission_in, author_id=author_id) + return crud.submission.create_with_author( + db, obj_in=submission_in, author_id=author_id + ) def test_create_submission_suggestion_with_author(db: Session) -> None: diff --git a/chafan_core/tests/app/crud/b/test_crud_topic.py b/chafan_core/tests/app/crud/b/test_crud_topic.py index 201141f..85b9bbc 100644 --- a/chafan_core/tests/app/crud/b/test_crud_topic.py +++ b/chafan_core/tests/app/crud/b/test_crud_topic.py @@ -81,7 +81,9 @@ def test_get_category_topics(db: Session) -> None: regular_topic = crud.topic.create(db, obj_in=regular_topic_in) # Create a category topic - category_topic_in = TopicCreate(name=f"Category Topic {random_short_lower_string()}") + category_topic_in = TopicCreate( + name=f"Category Topic {random_short_lower_string()}" + ) category_topic = crud.topic.create(db, obj_in=category_topic_in) crud.topic.update(db, db_obj=category_topic, obj_in={"is_category": True}) diff --git a/chafan_core/tests/app/crud/b/test_crud_webhook.py b/chafan_core/tests/app/crud/b/test_crud_webhook.py index 6c976f1..4946bd4 100644 --- a/chafan_core/tests/app/crud/b/test_crud_webhook.py +++ b/chafan_core/tests/app/crud/b/test_crud_webhook.py @@ -99,9 +99,7 @@ def test_get_webhook_by_id(db: Session) -> None: webhook_in = WebhookCreate( site_uuid=site.uuid, - event_spec=WebhookEventSpec( - content=WebhookSiteEvent(new_question=True) - ), + event_spec=WebhookEventSpec(content=WebhookSiteEvent(new_question=True)), secret="secret", callback_url="https://example.com/webhook", ) @@ -126,9 +124,7 @@ def test_update_webhook_enabled(db: Session) -> None: webhook_in = WebhookCreate( site_uuid=site.uuid, - event_spec=WebhookEventSpec( - content=WebhookSiteEvent(new_question=True) - ), + event_spec=WebhookEventSpec(content=WebhookSiteEvent(new_question=True)), secret="secret", callback_url="https://example.com/webhook", ) @@ -151,9 +147,7 @@ def test_update_webhook_callback_url(db: Session) -> None: webhook_in = WebhookCreate( site_uuid=site.uuid, - event_spec=WebhookEventSpec( - content=WebhookSiteEvent(new_question=True) - ), + event_spec=WebhookEventSpec(content=WebhookSiteEvent(new_question=True)), secret="secret", callback_url="https://old.example.com/webhook", ) @@ -175,9 +169,7 @@ def test_update_webhook_secret(db: Session) -> None: webhook_in = WebhookCreate( site_uuid=site.uuid, - event_spec=WebhookEventSpec( - content=WebhookSiteEvent(new_question=True) - ), + event_spec=WebhookEventSpec(content=WebhookSiteEvent(new_question=True)), secret="old-secret", callback_url="https://example.com/webhook", ) @@ -200,9 +192,7 @@ def test_webhook_timestamps(db: Session) -> None: webhook_in = WebhookCreate( site_uuid=site.uuid, - event_spec=WebhookEventSpec( - content=WebhookSiteEvent(new_question=True) - ), + event_spec=WebhookEventSpec(content=WebhookSiteEvent(new_question=True)), secret="secret", callback_url="https://example.com/webhook", ) @@ -224,9 +214,7 @@ def test_multiple_webhooks_for_same_site(db: Session) -> None: for i in range(3): webhook_in = WebhookCreate( site_uuid=site.uuid, - event_spec=WebhookEventSpec( - content=WebhookSiteEvent(new_question=True) - ), + event_spec=WebhookEventSpec(content=WebhookSiteEvent(new_question=True)), secret=f"secret-{i}", callback_url=f"https://example.com/webhook{i}", ) @@ -245,18 +233,14 @@ def test_webhooks_for_different_sites(db: Session) -> None: webhook1_in = WebhookCreate( site_uuid=site1.uuid, - event_spec=WebhookEventSpec( - content=WebhookSiteEvent(new_question=True) - ), + event_spec=WebhookEventSpec(content=WebhookSiteEvent(new_question=True)), secret="secret1", callback_url="https://example1.com/webhook", ) webhook2_in = WebhookCreate( site_uuid=site2.uuid, - event_spec=WebhookEventSpec( - content=WebhookSiteEvent(new_answer=True) - ), + event_spec=WebhookEventSpec(content=WebhookSiteEvent(new_answer=True)), secret="secret2", callback_url="https://example2.com/webhook", ) diff --git a/chafan_core/tests/app/email/test_email.py b/chafan_core/tests/app/email/test_email.py index fe7cc61..34c23b0 100644 --- a/chafan_core/tests/app/email/test_email.py +++ b/chafan_core/tests/app/email/test_email.py @@ -1,17 +1,19 @@ import logging + logger = logging.getLogger(__name__) -from chafan_core.app.email.utils import send_reset_password_email, apply_email_template +from chafan_core.app.email.utils import send_reset_password_email, apply_email_template from chafan_core.app.email.mock_client import MockEmailClient from chafan_core.app.config import settings import pytest import jinja2 + def test_apply_email_template(): result = apply_email_template("reset_password", {}, allow_undefined=True) assert isinstance(result, str) - assert len(result)>100 + assert len(result) > 100 with pytest.raises(jinja2.exceptions.UndefinedError) as exec_info: _ = apply_email_template("reset_password", {}) assert "undefined" in str(exec_info.value) @@ -23,8 +25,7 @@ def test_client(): client.send_email("stub@cha.fan", "stub_receiver@cha.fan", "subject", "test mail") client.quit() + async def test_send_reset_password_email(): settings.EMAILS_ENABLED = False await send_reset_password_email("test@cha.fan", "stub_token") - - diff --git a/chafan_core/tests/app/test_feed.py b/chafan_core/tests/app/test_feed.py index c93f5e5..1613203 100644 --- a/chafan_core/tests/app/test_feed.py +++ b/chafan_core/tests/app/test_feed.py @@ -1,7 +1,4 @@ - # from chafan_core.app.feed import new_activity_into_feed, ActivityType # TODO feed.py is dependent on curd, making it hard to run in a unit test 2025-07-19 def test_activity_to_feed(): a = 1 - - diff --git a/chafan_core/tests/conftest.py b/chafan_core/tests/conftest.py index d992067..130f2c6 100644 --- a/chafan_core/tests/conftest.py +++ b/chafan_core/tests/conftest.py @@ -22,6 +22,7 @@ # Core Fixtures - Database, Client, Authentication # ============================================================================= + @pytest.fixture(scope="session") def db() -> Generator[Session, None, None]: """ @@ -50,6 +51,7 @@ def client() -> Generator[TestClient, None, None]: # User Authentication Fixtures # ============================================================================= + @pytest.fixture(scope="module") def superuser_token_headers(client: TestClient) -> Dict[str, str]: """Authentication headers for superuser (admin).""" @@ -60,9 +62,10 @@ def superuser_token_headers(client: TestClient) -> Dict[str, str]: def normal_user_token_headers(client: TestClient, db: Session) -> Dict[str, str]: """Authentication headers for normal test user.""" import asyncio - return asyncio.run(authentication_token_from_email( - client=client, email=EMAIL_TEST_USER, db=db - )) + + return asyncio.run( + authentication_token_from_email(client=client, email=EMAIL_TEST_USER, db=db) + ) @pytest.fixture(scope="module") @@ -85,9 +88,12 @@ def normal_user_uuid(client: TestClient, normal_user_token_headers: dict) -> str def moderator_user_token_headers(client: TestClient, db: Session) -> Dict[str, str]: """Authentication headers for moderator test user.""" import asyncio - return asyncio.run(authentication_token_from_email( - client=client, email=EMAIL_TEST_MODERATOR, db=db - )) + + return asyncio.run( + authentication_token_from_email( + client=client, email=EMAIL_TEST_MODERATOR, db=db + ) + ) @pytest.fixture(scope="module") @@ -110,6 +116,7 @@ def moderator_user_uuid(client: TestClient, moderator_user_token_headers: dict) # Helper Function - Reduce Duplication # ============================================================================= + def ensure_user_in_site( client: TestClient, db: Session, @@ -126,9 +133,7 @@ def ensure_user_in_site( site = crud.site.get_by_uuid(db, uuid=site_uuid) assert site is not None, f"Site {site_uuid} not found" - profile = crud.profile.get_by_user_and_site( - db, owner_id=user_id, site_id=site.id - ) + profile = crud.profile.get_by_user_and_site(db, owner_id=user_id, site_id=site.id) if not profile: r = client.post( @@ -143,6 +148,7 @@ def ensure_user_in_site( # Test Site Fixture # ============================================================================= + @pytest.fixture(scope="module") def example_site_uuid( client: TestClient, @@ -182,6 +188,7 @@ def example_site_uuid( # Helper Function - Ensure Coin Balance # ============================================================================= + def ensure_user_has_coins(db: Session, user_id: int, coins: int = 100) -> None: """ Ensure a user has sufficient coins for testing. @@ -208,6 +215,7 @@ def ensure_user_has_coins(db: Session, user_id: int, coins: int = 100) -> None: # Article Column and Article Fixtures # ============================================================================= + @pytest.fixture(scope="module") def example_article_column_uuid( client: TestClient, @@ -280,8 +288,12 @@ def normal_user_authored_question_uuid( """ # Ensure user is in site ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) # Create question @@ -314,8 +326,12 @@ def example_submission_uuid( """ # Ensure user is in site ensure_user_in_site( - client, db, normal_user_id, normal_user_uuid, - example_site_uuid, superuser_token_headers + client, + db, + normal_user_id, + normal_user_uuid, + example_site_uuid, + superuser_token_headers, ) # Create submission diff --git a/chafan_core/utils/validators.py b/chafan_core/utils/validators.py index 4b47e31..6f2d57c 100644 --- a/chafan_core/utils/validators.py +++ b/chafan_core/utils/validators.py @@ -93,3 +93,42 @@ def validate_UUID(value: str) -> str: UUID = Annotated[str, validate_UUID] + + +# Title validators as Annotated types +ArticleTitle = Annotated[str, AfterValidator(validate_article_title)] +QuestionTitle = Annotated[str, AfterValidator(validate_question_title)] +SubmissionTitle = Annotated[str, AfterValidator(validate_submission_title)] + +# Body validators as Annotated types +MessageBody = Annotated[str, AfterValidator(validate_message_body)] + +# Password validator as Annotated type +ValidPassword = Annotated[SecretStr, AfterValidator(validate_password)] + + +# Phone number validators +def validate_country_code(v: str) -> str: + if v.isdigit() and len(v) >= 1 and len(v) <= 3: + return v + raise ValueError(f"Invalid country code: {v}") + + +def validate_subscriber_number(v: str) -> str: + if v.isdigit() and len(v) >= 1 and len(v) <= 12: + return v + raise ValueError(f"Invalid subscriber number: {v}") + + +CountryCode = Annotated[str, AfterValidator(validate_country_code)] +SubscriberNumber = Annotated[str, AfterValidator(validate_subscriber_number)] + + +# Positive integer validator +def validate_positive_int(v: int) -> int: + if v <= 0: + raise ValueError("Value must be positive.") + return v + + +PositiveInt = Annotated[int, AfterValidator(validate_positive_int)] diff --git a/mypy.ini b/mypy.ini index 66d514f..64ddc69 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,21 +1,71 @@ [mypy] -plugins = pydantic.mypy -;, sqlmypy +plugins = pydantic.mypy strict_optional = True follow_imports = silent warn_redundant_casts = True -warn_unused_ignores = True -; disallow_any_generics = True +warn_unused_ignores = False check_untyped_defs = True -; no_implicit_reexport = True -# for strict mypy: (this is the tricky one :-)) -disallow_untyped_defs = True +# Relaxed for gradual adoption - enable incrementally +disallow_untyped_defs = False + +# Disable specific error codes that are common with SQLAlchemy legacy typing +disable_error_code = arg-type, return-value, assignment, attr-defined, var-annotated, override, misc, index, list-item, no-redef, union-attr [pydantic-mypy] init_forbid_extra = True init_typed = True warn_required_dynamic_aliases = True warn_untyped_fields = True + +# Ignore missing stubs for third-party libraries +[mypy-pytz.*] +ignore_missing_imports = True + +[mypy-boto3.*] +ignore_missing_imports = True + +[mypy-botocore.*] +ignore_missing_imports = True + +[mypy-jose.*] +ignore_missing_imports = True + +[mypy-redis.*] +ignore_missing_imports = True + +[mypy-feedgen.*] +ignore_missing_imports = True + +[mypy-dramatiq.*] +ignore_missing_imports = True + +[mypy-apscheduler.*] +ignore_missing_imports = True + +[mypy-jieba.*] +ignore_missing_imports = True + +[mypy-sentry_sdk.*] +ignore_missing_imports = True + +[mypy-starlette.*] +ignore_missing_imports = True + +[mypy-uvicorn.*] +ignore_missing_imports = True + +[mypy-requests.*] +ignore_missing_imports = True + +[mypy-whoosh.*] +ignore_missing_imports = True + +[mypy-emails.*] +ignore_missing_imports = True + +# Ignore all errors in test files for now - can be enabled gradually +[mypy-chafan_core.tests.*] +ignore_errors = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d2b3bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.isort] +profile = "black" +skip = ["chafan_core/e2e_tests/main.py"] + +[tool.black] +line-length = 88 diff --git a/scripts/static_analysis/lint.sh b/scripts/static_analysis/lint.sh index 0711000..2ed928a 100755 --- a/scripts/static_analysis/lint.sh +++ b/scripts/static_analysis/lint.sh @@ -2,7 +2,7 @@ set -xe -mypy chafan_core || true -black chafan_core --check || true -isort --check-only --skip chafan_core/e2e_tests/main.py chafan_core || true -flake8 chafan_core --max-line-length 99 --select=E9,E63,F7,F82 || true +mypy chafan_core +black chafan_core --check +isort --check-only --skip chafan_core/tests chafan_core +flake8 chafan_core --max-line-length 99 --select=E9,E63,F7,F82