0.4.10-a
All checks were successful
Deploy on push / deploy (push) Successful in 44s

This commit is contained in:
Untone 2025-02-11 12:00:35 +03:00
parent 25b61c6b29
commit 5d87035885
27 changed files with 299 additions and 536 deletions

View File

@ -1,5 +1,7 @@
#### [0.4.10] - 2025-02-10 #### [0.4.10] - 2025-02-10
- `add_author_stat_columns` fixed - `add_author_stat_columns` fixed
- `Draft` orm and schema tuning and fixes
- `create_draft` and `update_draft` mutations and resolvers fixed
#### [0.4.9] - 2025-02-09 #### [0.4.9] - 2025-02-09

View File

@ -1,15 +1,16 @@
from binascii import hexlify from binascii import hexlify
from hashlib import sha256 from hashlib import sha256
# from base.exceptions import InvalidPassword, InvalidToken
from services.db import local_session
from auth.exceptions import ExpiredToken, InvalidToken
from passlib.hash import bcrypt from passlib.hash import bcrypt
from auth.exceptions import ExpiredToken, InvalidToken
from auth.jwtcodec import JWTCodec from auth.jwtcodec import JWTCodec
from auth.tokenstorage import TokenStorage from auth.tokenstorage import TokenStorage
from orm.user import User from orm.user import User
# from base.exceptions import InvalidPassword, InvalidToken
from services.db import local_session
class Password: class Password:
@staticmethod @staticmethod

View File

@ -1,9 +1,8 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from services.redis import redis
from auth.validations import AuthInput
from auth.jwtcodec import JWTCodec from auth.jwtcodec import JWTCodec
from auth.validations import AuthInput
from services.redis import redis
from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN from settings import ONETIME_TOKEN_LIFE_SPAN, SESSION_TOKEN_LIFE_SPAN

View File

@ -1,6 +1,15 @@
import time import time
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, func from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
func,
)
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from services.db import Base from services.db import Base

View File

@ -1,31 +1,36 @@
import re import re
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
# RFC 5322 compliant email regex pattern # RFC 5322 compliant email regex pattern
EMAIL_PATTERN = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" EMAIL_PATTERN = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
class AuthInput(BaseModel): class AuthInput(BaseModel):
"""Base model for authentication input validation""" """Base model for authentication input validation"""
user_id: str = Field(description="Unique user identifier") user_id: str = Field(description="Unique user identifier")
username: str = Field(min_length=2, max_length=50) username: str = Field(min_length=2, max_length=50)
token: str = Field(min_length=32) token: str = Field(min_length=32)
@field_validator('user_id') @field_validator("user_id")
@classmethod @classmethod
def validate_user_id(cls, v: str) -> str: def validate_user_id(cls, v: str) -> str:
if not v.strip(): if not v.strip():
raise ValueError("user_id cannot be empty") raise ValueError("user_id cannot be empty")
return v return v
class UserRegistrationInput(BaseModel): class UserRegistrationInput(BaseModel):
"""Validation model for user registration""" """Validation model for user registration"""
email: str = Field(max_length=254) # Max email length per RFC 5321 email: str = Field(max_length=254) # Max email length per RFC 5321
password: str = Field(min_length=8, max_length=100) password: str = Field(min_length=8, max_length=100)
name: str = Field(min_length=2, max_length=50) name: str = Field(min_length=2, max_length=50)
@field_validator('email') @field_validator("email")
@classmethod @classmethod
def validate_email(cls, v: str) -> str: def validate_email(cls, v: str) -> str:
"""Validate email format""" """Validate email format"""
@ -33,7 +38,7 @@ class UserRegistrationInput(BaseModel):
raise ValueError("Invalid email format") raise ValueError("Invalid email format")
return v.lower() return v.lower()
@field_validator('password') @field_validator("password")
@classmethod @classmethod
def validate_password_strength(cls, v: str) -> str: def validate_password_strength(cls, v: str) -> str:
"""Validate password meets security requirements""" """Validate password meets security requirements"""
@ -47,57 +52,65 @@ class UserRegistrationInput(BaseModel):
raise ValueError("Password must contain at least one special character") raise ValueError("Password must contain at least one special character")
return v return v
class UserLoginInput(BaseModel): class UserLoginInput(BaseModel):
"""Validation model for user login""" """Validation model for user login"""
email: str = Field(max_length=254) email: str = Field(max_length=254)
password: str = Field(min_length=8, max_length=100) password: str = Field(min_length=8, max_length=100)
@field_validator('email') @field_validator("email")
@classmethod @classmethod
def validate_email(cls, v: str) -> str: def validate_email(cls, v: str) -> str:
if not re.match(EMAIL_PATTERN, v): if not re.match(EMAIL_PATTERN, v):
raise ValueError("Invalid email format") raise ValueError("Invalid email format")
return v.lower() return v.lower()
class TokenPayload(BaseModel): class TokenPayload(BaseModel):
"""Validation model for JWT token payload""" """Validation model for JWT token payload"""
user_id: str user_id: str
username: str username: str
exp: datetime exp: datetime
iat: datetime iat: datetime
scopes: Optional[List[str]] = [] scopes: Optional[List[str]] = []
class OAuthInput(BaseModel): class OAuthInput(BaseModel):
"""Validation model for OAuth input""" """Validation model for OAuth input"""
provider: str = Field(pattern='^(google|github|facebook)$')
provider: str = Field(pattern="^(google|github|facebook)$")
code: str code: str
redirect_uri: Optional[str] = None redirect_uri: Optional[str] = None
@field_validator('provider') @field_validator("provider")
@classmethod @classmethod
def validate_provider(cls, v: str) -> str: def validate_provider(cls, v: str) -> str:
valid_providers = ['google', 'github', 'facebook'] valid_providers = ["google", "github", "facebook"]
if v.lower() not in valid_providers: if v.lower() not in valid_providers:
raise ValueError(f"Provider must be one of: {', '.join(valid_providers)}") raise ValueError(f"Provider must be one of: {', '.join(valid_providers)}")
return v.lower() return v.lower()
class AuthResponse(BaseModel): class AuthResponse(BaseModel):
"""Validation model for authentication responses""" """Validation model for authentication responses"""
success: bool success: bool
token: Optional[str] = None token: Optional[str] = None
error: Optional[str] = None error: Optional[str] = None
user: Optional[Dict[str, Union[str, int, bool]]] = None user: Optional[Dict[str, Union[str, int, bool]]] = None
@field_validator('error') @field_validator("error")
@classmethod @classmethod
def validate_error_if_not_success(cls, v: Optional[str], info) -> Optional[str]: def validate_error_if_not_success(cls, v: Optional[str], info) -> Optional[str]:
if not info.data.get('success') and not v: if not info.data.get("success") and not v:
raise ValueError("Error message required when success is False") raise ValueError("Error message required when success is False")
return v return v
@field_validator('token') @field_validator("token")
@classmethod @classmethod
def validate_token_if_success(cls, v: Optional[str], info) -> Optional[str]: def validate_token_if_success(cls, v: Optional[str], info) -> Optional[str]:
if info.data.get('success') and not v: if info.data.get("success") and not v:
raise ValueError("Token required when success is True") raise ValueError("Token required when success is True")
return v return v

View File

@ -28,27 +28,27 @@ class DraftAuthor(Base):
class Draft(Base): class Draft(Base):
__tablename__ = "draft" __tablename__ = "draft"
# required
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time())) created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
updated_at: int | None = Column(Integer, nullable=True, index=True) created_by: int = Column(ForeignKey("author.id"), nullable=False)
deleted_at: int | None = Column(Integer, nullable=True, index=True)
body: str = Column(String, nullable=False, comment="Body") # optional
layout: str = Column(String, nullable=True, default="article")
slug: str = Column(String, unique=True) slug: str = Column(String, unique=True)
cover: str | None = Column(String, nullable=True, comment="Cover image url") title: str = Column(String, nullable=True)
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption") subtitle: str | None = Column(String, nullable=True)
lead: str | None = Column(String, nullable=True) lead: str | None = Column(String, nullable=True)
description: str | None = Column(String, nullable=True) description: str | None = Column(String, nullable=True)
title: str = Column(String, nullable=False) body: str = Column(String, nullable=False, comment="Body")
subtitle: str | None = Column(String, nullable=True)
layout: str = Column(String, nullable=False, default="article")
media: dict | None = Column(JSON, nullable=True) media: dict | None = Column(JSON, nullable=True)
cover: str | None = Column(String, nullable=True, comment="Cover image url")
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
lang: str = Column(String, nullable=False, default="ru", comment="Language") lang: str = Column(String, nullable=False, default="ru", comment="Language")
oid: str | None = Column(String, nullable=True)
seo: str | None = Column(String, nullable=True) # JSON seo: str | None = Column(String, nullable=True) # JSON
created_by: int = Column(ForeignKey("author.id"), nullable=False) # auto
updated_at: int | None = Column(Integer, nullable=True, index=True)
deleted_at: int | None = Column(Integer, nullable=True, index=True)
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True) updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True) deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
authors = relationship(Author, secondary="draft_author") authors = relationship(Author, secondary="draft_author")

View File

@ -1,176 +0,0 @@
from services.db import REGISTRY, Base, local_session
from utils.logger import root_logger as logger
from sqlalchemy.types import TypeDecorator
from sqlalchemy.types import String
from sqlalchemy import Column, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship
class ClassType(TypeDecorator):
impl = String
@property
def python_type(self):
return NotImplemented
def process_literal_param(self, value, dialect):
return NotImplemented
def process_bind_param(self, value, dialect):
return value.__name__ if isinstance(value, type) else str(value)
def process_result_value(self, value, dialect):
class_ = REGISTRY.get(value)
if class_ is None:
logger.warn(f"Can't find class <{value}>,find it yourself!", stacklevel=2)
return class_
class Role(Base):
__tablename__ = "role"
name = Column(String, nullable=False, comment="Role Name")
desc = Column(String, nullable=True, comment="Role Description")
community = Column(
ForeignKey("community.id", ondelete="CASCADE"),
nullable=False,
comment="Community",
)
permissions = relationship(lambda: Permission)
@staticmethod
def init_table():
with local_session() as session:
r = session.query(Role).filter(Role.name == "author").first()
if r:
Role.default_role = r
return
r1 = Role.create(
name="author",
desc="Role for an author",
community=1,
)
session.add(r1)
Role.default_role = r1
r2 = Role.create(
name="reader",
desc="Role for a reader",
community=1,
)
session.add(r2)
r3 = Role.create(
name="expert",
desc="Role for an expert",
community=1,
)
session.add(r3)
r4 = Role.create(
name="editor",
desc="Role for an editor",
community=1,
)
session.add(r4)
class Operation(Base):
__tablename__ = "operation"
name = Column(String, nullable=False, unique=True, comment="Operation Name")
@staticmethod
def init_table():
with local_session() as session:
for name in ["create", "update", "delete", "load"]:
"""
* everyone can:
- load shouts
- load topics
- load reactions
- create an account to become a READER
* readers can:
- update and delete their account
- load chats
- load messages
- create reaction of some shout's author allowed kinds
- create shout to become an AUTHOR
* authors can:
- update and delete their shout
- invite other authors to edit shout and chat
- manage allowed reactions for their shout
* pros can:
- create/update/delete their community
- create/update/delete topics for their community
"""
op = session.query(Operation).filter(Operation.name == name).first()
if not op:
op = Operation.create(name=name)
session.add(op)
session.commit()
class Resource(Base):
__tablename__ = "resource"
resourceClass = Column(String, nullable=False, unique=True, comment="Resource class")
name = Column(String, nullable=False, unique=True, comment="Resource name")
# TODO: community = Column(ForeignKey())
@staticmethod
def init_table():
with local_session() as session:
for res in [
"shout",
"topic",
"reaction",
"chat",
"message",
"invite",
"community",
"user",
]:
r = session.query(Resource).filter(Resource.name == res).first()
if not r:
r = Resource.create(name=res, resourceClass=res)
session.add(r)
session.commit()
class Permission(Base):
__tablename__ = "permission"
__table_args__ = (
UniqueConstraint("role", "operation", "resource"),
{"extend_existing": True},
)
role: Column = Column(ForeignKey("role.id", ondelete="CASCADE"), nullable=False, comment="Role")
operation: Column = Column(
ForeignKey("operation.id", ondelete="CASCADE"),
nullable=False,
comment="Operation",
)
resource: Column = Column(
ForeignKey("resource.id", ondelete="CASCADE"),
nullable=False,
comment="Resource",
)
# if __name__ == "__main__":
# Base.metadata.create_all(engine)
# ops = [
# Permission(role=1, operation=1, resource=1),
# Permission(role=1, operation=2, resource=1),
# Permission(role=1, operation=3, resource=1),
# Permission(role=1, operation=4, resource=1),
# Permission(role=2, operation=4, resource=1),
# ]
# global_session.add_all(ops)
# global_session.commit()

View File

@ -1,105 +0,0 @@
from sqlalchemy import JSON as JSONType
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import relationship
from services.db import Base, local_session
from orm.rbac import Role
class UserRating(Base):
__tablename__ = "user_rating"
id = None
rater: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
user: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
value: Column = Column(Integer)
@staticmethod
def init_table():
pass
class UserRole(Base):
__tablename__ = "user_role"
id = None
user = Column(ForeignKey("user.id"), primary_key=True, index=True)
role = Column(ForeignKey("role.id"), primary_key=True, index=True)
class AuthorFollower(Base):
__tablename__ = "author_follower"
id = None
follower: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
author: Column = Column(ForeignKey("user.id"), primary_key=True, index=True)
createdAt = Column(
DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
auto = Column(Boolean, nullable=False, default=False)
class User(Base):
__tablename__ = "user"
default_user = None
email = Column(String, unique=True, nullable=False, comment="Email")
username = Column(String, nullable=False, comment="Login")
password = Column(String, nullable=True, comment="Password")
bio = Column(String, nullable=True, comment="Bio") # status description
about = Column(String, nullable=True, comment="About") # long and formatted
userpic = Column(String, nullable=True, comment="Userpic")
name = Column(String, nullable=True, comment="Display name")
slug = Column(String, unique=True, comment="User's slug")
muted = Column(Boolean, default=False)
emailConfirmed = Column(Boolean, default=False)
createdAt = Column(
DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Created at"
)
lastSeen = Column(
DateTime(timezone=True), nullable=False, server_default=func.now(), comment="Was online at"
)
deletedAt = Column(DateTime(timezone=True), nullable=True, comment="Deleted at")
links = Column(JSONType, nullable=True, comment="Links")
oauth = Column(String, nullable=True)
ratings = relationship(UserRating, foreign_keys=UserRating.user)
roles = relationship(lambda: Role, secondary=UserRole.__tablename__)
oid = Column(String, nullable=True)
@staticmethod
def init_table():
with local_session() as session:
default = session.query(User).filter(User.slug == "anonymous").first()
if not default:
default_dict = {
"email": "noreply@discours.io",
"username": "noreply@discours.io",
"name": "Аноним",
"slug": "anonymous",
}
default = User.create(**default_dict)
session.add(default)
discours_dict = {
"email": "welcome@discours.io",
"username": "welcome@discours.io",
"name": "Дискурс",
"slug": "discours",
}
discours = User.create(**discours_dict)
session.add(discours)
session.commit()
User.default_user = default
def get_permission(self):
scope = {}
for role in self.roles:
for p in role.permissions:
if p.resource not in scope:
scope[p.resource] = set()
scope[p.resource].add(p.operation)
print(scope)
return scope
# if __name__ == "__main__":
# print(User.get_permission(user_id=1))

View File

@ -1,22 +1,25 @@
import time import time
from orm.topic import Topic
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.sql import and_ from sqlalchemy.sql import and_
from cache.cache import ( from cache.cache import (
cache_author, cache_by_id, cache_topic, cache_author,
invalidate_shout_related_cache, invalidate_shouts_cache cache_by_id,
cache_topic,
invalidate_shout_related_cache,
invalidate_shouts_cache,
) )
from orm.author import Author from orm.author import Author
from orm.draft import Draft from orm.draft import Draft
from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic
from orm.topic import Topic
from services.auth import login_required from services.auth import login_required
from services.db import local_session from services.db import local_session
from services.schema import mutation, query
from utils.logger import root_logger as logger
from services.notify import notify_shout from services.notify import notify_shout
from services.schema import mutation, query
from services.search import search_service from services.search import search_service
from utils.logger import root_logger as logger
def create_shout_from_draft(session, draft, author_id): def create_shout_from_draft(session, draft, author_id):
@ -59,16 +62,19 @@ async def load_drafts(_, info):
@mutation.field("create_draft") @mutation.field("create_draft")
@login_required @login_required
async def create_draft(_, info, shout_id: int = 0): async def create_draft(_, info, draft_input):
user_id = info.context.get("user_id") user_id = info.context.get("user_id")
author_dict = info.context.get("author", {}) author_dict = info.context.get("author", {})
author_id = author_dict.get("id") author_id = author_dict.get("id")
draft_id = draft_input.get("id")
if not draft_id:
return {"error": "Draft ID is required"}
if not user_id or not author_id: if not user_id or not author_id:
return {"error": "User ID and author ID are required"} return {"error": "Author ID are required"}
with local_session() as session: with local_session() as session:
draft = Draft(created_by=author_id) draft = Draft(created_by=author_id, **draft_input)
session.add(draft) session.add(draft)
session.commit() session.commit()
return {"draft": draft} return {"draft": draft}
@ -81,11 +87,14 @@ async def update_draft(_, info, draft_input):
author_dict = info.context.get("author", {}) author_dict = info.context.get("author", {})
author_id = author_dict.get("id") author_id = author_dict.get("id")
draft_id = draft_input.get("id") draft_id = draft_input.get("id")
if not draft_id:
return {"error": "Draft ID is required"}
if not user_id or not author_id: if not user_id or not author_id:
return {"error": "User ID and author ID are required"} return {"error": "Author ID are required"}
with local_session() as session: with local_session() as session:
draft = session.query(Draft).filter(Draft.id == draft_id).first() draft = session.query(Draft).filter(Draft.id == draft_id).first()
del draft_input["id"]
Draft.update(draft, {**draft_input}) Draft.update(draft, {**draft_input})
if not draft: if not draft:
return {"error": "Draft not found"} return {"error": "Draft not found"}
@ -129,7 +138,7 @@ async def publish_draft(_, info, draft_id: int):
shout = create_shout_from_draft(session, draft, author_id) shout = create_shout_from_draft(session, draft, author_id)
session.add(shout) session.add(shout)
session.commit() session.commit()
return {"shout": shout} return {"shout": shout, "draft": draft}
@mutation.field("unpublish_draft") @mutation.field("unpublish_draft")
@ -149,13 +158,13 @@ async def unpublish_draft(_, info, draft_id: int):
if shout: if shout:
shout.published_at = None shout.published_at = None
session.commit() session.commit()
return {"shout": shout} return {"shout": shout, "draft": draft}
return {"error": "Failed to unpublish draft"} return {"error": "Failed to unpublish draft"}
@mutation.field("publish_shout") @mutation.field("publish_shout")
@login_required @login_required
async def publish_shout(_, info, shout_id: int, draft=None): async def publish_shout(_, info, shout_id: int):
"""Publish draft as a shout or update existing shout. """Publish draft as a shout or update existing shout.
Args: Args:
@ -207,9 +216,11 @@ async def publish_shout(_, info, shout_id: int, draft=None):
shout.published_at = now shout.published_at = now
# Обрабатываем связи с авторами # Обрабатываем связи с авторами
if not session.query(ShoutAuthor).filter( if (
and_(ShoutAuthor.shout == shout.id, ShoutAuthor.author == author_id) not session.query(ShoutAuthor)
).first(): .filter(and_(ShoutAuthor.shout == shout.id, ShoutAuthor.author == author_id))
.first()
):
sa = ShoutAuthor(shout=shout.id, author=author_id) sa = ShoutAuthor(shout=shout.id, author=author_id)
session.add(sa) session.add(sa)
@ -217,9 +228,7 @@ async def publish_shout(_, info, shout_id: int, draft=None):
if draft.topics: if draft.topics:
for topic in draft.topics: for topic in draft.topics:
st = ShoutTopic( st = ShoutTopic(
topic=topic.id, topic=topic.id, shout=shout.id, main=topic.main if hasattr(topic, "main") else False
shout=shout.id,
main=topic.main if hasattr(topic, 'main') else False
) )
session.add(st) session.add(st)
@ -229,12 +238,7 @@ async def publish_shout(_, info, shout_id: int, draft=None):
# Инвалидируем кэш только если это новая публикация или была снята с публикации # Инвалидируем кэш только если это новая публикация или была снята с публикации
if not was_published: if not was_published:
cache_keys = [ cache_keys = ["feed", f"author_{author_id}", "random_top", "unrated"]
"feed",
f"author_{author_id}",
"random_top",
"unrated"
]
# Добавляем ключи для тем # Добавляем ключи для тем
for topic in shout.topics: for topic in shout.topics:
@ -264,7 +268,7 @@ async def publish_shout(_, info, shout_id: int, draft=None):
except Exception as e: except Exception as e:
logger.error(f"Failed to publish shout: {e}", exc_info=True) logger.error(f"Failed to publish shout: {e}", exc_info=True)
if 'session' in locals(): if "session" in locals():
session.rollback() session.rollback()
return {"error": f"Failed to publish shout: {str(e)}"} return {"error": f"Failed to publish shout: {str(e)}"}
@ -299,5 +303,3 @@ async def unpublish_shout(_, info, shout_id: int):
return {"error": "Failed to unpublish shout"} return {"error": "Failed to unpublish shout"}
return {"shout": shout} return {"shout": shout}

View File

@ -5,7 +5,12 @@ from sqlalchemy import and_, desc, select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.sql.functions import coalesce from sqlalchemy.sql.functions import coalesce
from cache.cache import cache_author, cache_topic, invalidate_shout_related_cache, invalidate_shouts_cache from cache.cache import (
cache_author,
cache_topic,
invalidate_shout_related_cache,
invalidate_shouts_cache,
)
from orm.author import Author from orm.author import Author
from orm.draft import Draft from orm.draft import Draft
from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic
@ -114,11 +119,11 @@ async def get_my_shout(_, info, shout_id: int):
logger.debug(f"got {len(shout.authors)} shout authors, created by {shout.created_by}") logger.debug(f"got {len(shout.authors)} shout authors, created by {shout.created_by}")
is_editor = "editor" in roles is_editor = "editor" in roles
logger.debug(f'viewer is{'' if is_editor else ' not'} editor') logger.debug(f"viewer is{'' if is_editor else ' not'} editor")
is_creator = author_id == shout.created_by is_creator = author_id == shout.created_by
logger.debug(f'viewer is{'' if is_creator else ' not'} creator') logger.debug(f"viewer is{'' if is_creator else ' not'} creator")
is_author = bool(list(filter(lambda x: x.id == int(author_id), [x for x in shout.authors]))) is_author = bool(list(filter(lambda x: x.id == int(author_id), [x for x in shout.authors])))
logger.debug(f'viewer is{'' if is_creator else ' not'} author') logger.debug(f"viewer is{'' if is_creator else ' not'} author")
can_edit = is_editor or is_author or is_creator can_edit = is_editor or is_author or is_creator
if not can_edit: if not can_edit:

View File

@ -5,7 +5,12 @@ from sqlalchemy import and_, select
from orm.author import Author, AuthorFollower from orm.author import Author, AuthorFollower
from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic
from orm.topic import Topic, TopicFollower from orm.topic import Topic, TopicFollower
from resolvers.reader import apply_options, get_shouts_with_links, has_field, query_with_stat from resolvers.reader import (
apply_options,
get_shouts_with_links,
has_field,
query_with_stat,
)
from services.auth import login_required from services.auth import login_required
from services.db import local_session from services.db import local_session
from services.schema import query from services.schema import query

View File

@ -67,10 +67,7 @@ def add_author_stat_columns(q):
shouts_subq = ( shouts_subq = (
select(func.count(distinct(Shout.id))) select(func.count(distinct(Shout.id)))
.select_from(ShoutAuthor) .select_from(ShoutAuthor)
.join(Shout, and_( .join(Shout, and_(Shout.id == ShoutAuthor.shout, Shout.deleted_at.is_(None)))
Shout.id == ShoutAuthor.shout,
Shout.deleted_at.is_(None)
))
.where(ShoutAuthor.author == Author.id) .where(ShoutAuthor.author == Author.id)
.scalar_subquery() .scalar_subquery()
) )
@ -85,10 +82,7 @@ def add_author_stat_columns(q):
# Основной запрос # Основной запрос
q = ( q = (
q.select_from(Author) q.select_from(Author)
.add_columns( .add_columns(shouts_subq.label("shouts_stat"), followers_subq.label("followers_stat"))
shouts_subq.label("shouts_stat"),
followers_subq.label("followers_stat")
)
.group_by(Author.id) .group_by(Author.id)
) )

View File

@ -66,11 +66,11 @@ async def get_topic(_, _info, slug: str):
# Мутация для создания новой темы # Мутация для создания новой темы
@mutation.field("create_topic") @mutation.field("create_topic")
@login_required @login_required
async def create_topic(_, _info, inp): async def create_topic(_, _info, topic_input):
with local_session() as session: with local_session() as session:
# TODO: проверить права пользователя на создание темы для конкретного сообщества # TODO: проверить права пользователя на создание темы для конкретного сообщества
# и разрешение на создание # и разрешение на создание
new_topic = Topic(**inp) new_topic = Topic(**topic_input)
session.add(new_topic) session.add(new_topic)
session.commit() session.commit()
@ -80,14 +80,14 @@ async def create_topic(_, _info, inp):
# Мутация для обновления темы # Мутация для обновления темы
@mutation.field("update_topic") @mutation.field("update_topic")
@login_required @login_required
async def update_topic(_, _info, inp): async def update_topic(_, _info, topic_input):
slug = inp["slug"] slug = topic_input["slug"]
with local_session() as session: with local_session() as session:
topic = session.query(Topic).filter(Topic.slug == slug).first() topic = session.query(Topic).filter(Topic.slug == slug).first()
if not topic: if not topic:
return {"error": "topic not found"} return {"error": "topic not found"}
else: else:
Topic.update(topic, inp) Topic.update(topic, topic_input)
session.add(topic) session.add(topic)
session.commit() session.commit()

View File

@ -1,15 +1,47 @@
input DraftInput { input MediaItemInput {
slug: String url: String
title: String title: String
body: String body: String
source: String
pic: String
date: String
genre: String
artist: String
lyrics: String
}
input AuthorInput {
id: Int!
slug: String
}
input TopicInput {
id: Int
slug: String!
title: String
body: String
pic: String
}
input DraftInput {
id: Int
# no created_at, updated_at, deleted_at, updated_by, deleted_by
layout: String
shout_id: Int # Changed from shout: Shout
author_ids: [Int!] # Changed from authors: [Author]
topic_ids: [Int!] # Changed from topics: [Topic]
main_topic_id: Int # Changed from main_topic: Topic
media: [MediaItemInput] # Changed to use MediaItemInput
lead: String lead: String
description: String description: String
layout: String
media: String
topics: [TopicInput]
community: Int
subtitle: String subtitle: String
lang: String
seo: String
body: String
title: String
slug: String
cover: String cover: String
cover_caption: String
} }
input ProfileInput { input ProfileInput {
@ -21,14 +53,6 @@ input ProfileInput {
about: String about: String
} }
input TopicInput {
id: Int
slug: String!
title: String
body: String
pic: String
}
input ReactionInput { input ReactionInput {
id: Int id: Int
kind: ReactionKind! kind: ReactionKind!

View File

@ -4,8 +4,8 @@ type Mutation {
update_author(profile: ProfileInput!): CommonResult! update_author(profile: ProfileInput!): CommonResult!
# draft # draft
create_draft(input: DraftInput!): CommonResult! create_draft(draft_input: DraftInput!): CommonResult!
update_draft(draft_id: Int!, input: DraftInput!): CommonResult! update_draft(draft_id: Int!, draft_input: DraftInput!): CommonResult!
delete_draft(draft_id: Int!): CommonResult! delete_draft(draft_id: Int!): CommonResult!
# publication # publication
publish_shout(shout_id: Int!): CommonResult! publish_shout(shout_id: Int!): CommonResult!
@ -18,8 +18,8 @@ type Mutation {
unfollow(what: FollowingEntity!, slug: String!): AuthorFollowsResult! unfollow(what: FollowingEntity!, slug: String!): AuthorFollowsResult!
# topic # topic
create_topic(input: TopicInput!): CommonResult! create_topic(topic_input: TopicInput!): CommonResult!
update_topic(input: TopicInput!): CommonResult! update_topic(topic_input: TopicInput!): CommonResult!
delete_topic(slug: String!): CommonResult! delete_topic(slug: String!): CommonResult!
# reaction # reaction
@ -45,7 +45,7 @@ type Mutation {
# community # community
join_community(slug: String!): CommonResult! join_community(slug: String!): CommonResult!
leave_community(slug: String!): CommonResult! leave_community(slug: String!): CommonResult!
create_community(input: CommunityInput!): CommonResult! create_community(community_input: CommunityInput!): CommonResult!
update_community(input: CommunityInput!): CommonResult! update_community(community_input: CommunityInput!): CommonResult!
delete_community(slug: String!): CommonResult! delete_community(slug: String!): CommonResult!
} }

View File

@ -108,27 +108,30 @@ type Shout {
type Draft { type Draft {
id: Int! id: Int!
shout: Shout
created_at: Int! created_at: Int!
created_by: Author!
layout: String
slug: String
title: String
subtitle: String
lead: String
description: String
body: String
media: [MediaItem]
cover: String
cover_caption: String
lang: String
seo: String
# auto
updated_at: Int updated_at: Int
deleted_at: Int deleted_at: Int
created_by: Author!
updated_by: Author updated_by: Author
deleted_by: Author deleted_by: Author
authors: [Author] authors: [Author]
topics: [Topic] topics: [Topic]
media: [MediaItem]
lead: String
description: String
subtitle: String
layout: String
lang: String
seo: String
body: String
title: String
slug: String
cover: String
cover_caption: String
} }
type Stat { type Stat {

View File

@ -6,7 +6,17 @@ import warnings
from typing import Any, Callable, Dict, TypeVar from typing import Any, Callable, Dict, TypeVar
import sqlalchemy import sqlalchemy
from sqlalchemy import JSON, Column, Engine, Integer, create_engine, event, exc, func, inspect from sqlalchemy import (
JSON,
Column,
Engine,
Integer,
create_engine,
event,
exc,
func,
inspect,
)
from sqlalchemy.orm import Session, configure_mappers, declarative_base from sqlalchemy.orm import Session, configure_mappers, declarative_base
from sqlalchemy.sql.schema import Table from sqlalchemy.sql.schema import Table

View File

@ -1,8 +1,11 @@
import concurrent.futures import concurrent.futures
from typing import Dict, Tuple, List from typing import Dict, List, Tuple
from txtai.embeddings import Embeddings from txtai.embeddings import Embeddings
from services.logger import root_logger as logger from services.logger import root_logger as logger
class TopicClassifier: class TopicClassifier:
def __init__(self, shouts_by_topic: Dict[str, str], publications: List[Dict[str, str]]): def __init__(self, shouts_by_topic: Dict[str, str], publications: List[Dict[str, str]]):
""" """
@ -39,18 +42,12 @@ class TopicClassifier:
# Инициализируем embeddings для классификации тем # Инициализируем embeddings для классификации тем
self.topic_embeddings = Embeddings(path=model_path) self.topic_embeddings = Embeddings(path=model_path)
topic_documents = [ topic_documents = [(topic, text) for topic, text in self.shouts_by_topic.items()]
(topic, text)
for topic, text in self.shouts_by_topic.items()
]
self.topic_embeddings.index(topic_documents) self.topic_embeddings.index(topic_documents)
# Инициализируем embeddings для поиска публикаций # Инициализируем embeddings для поиска публикаций
self.search_embeddings = Embeddings(path=model_path) self.search_embeddings = Embeddings(path=model_path)
search_documents = [ search_documents = [(str(pub["id"]), f"{pub['title']} {pub['text']}") for pub in self.publications]
(str(pub['id']), f"{pub['title']} {pub['text']}")
for pub in self.publications
]
self.search_embeddings.index(search_documents) self.search_embeddings.index(search_documents)
logger.info("Подготовка векторных представлений завершена.") logger.info("Подготовка векторных представлений завершена.")
@ -101,15 +98,9 @@ class TopicClassifier:
found_publications = [] found_publications = []
for score, pub_id in results: for score, pub_id in results:
# Находим публикацию по id # Находим публикацию по id
publication = next( publication = next((pub for pub in self.publications if str(pub["id"]) == pub_id), None)
(pub for pub in self.publications if str(pub['id']) == pub_id),
None
)
if publication: if publication:
found_publications.append({ found_publications.append({**publication, "relevance": float(score)})
**publication,
'relevance': float(score)
})
return found_publications return found_publications
@ -137,6 +128,7 @@ class TopicClassifier:
if self._executor: if self._executor:
self._executor.shutdown(wait=False) self._executor.shutdown(wait=False)
# Пример использования: # Пример использования:
""" """
shouts_by_topic = { shouts_by_topic = {
@ -176,4 +168,3 @@ for pub in similar_publications:
print(f"Заголовок: {pub['title']}") print(f"Заголовок: {pub['title']}")
print(f"Текст: {pub['text'][:100]}...") print(f"Текст: {pub['text'][:100]}...")
""" """

View File

@ -43,7 +43,6 @@ async def request_graphql_data(gql, url=AUTH_URL, headers=None):
return None return None
def create_all_tables(): def create_all_tables():
"""Create all database tables in the correct order.""" """Create all database tables in the correct order."""
from orm import author, community, draft, notification, reaction, shout, topic, user from orm import author, community, draft, notification, reaction, shout, topic, user
@ -54,26 +53,21 @@ def create_all_tables():
author.Author, # Базовая таблица author.Author, # Базовая таблица
community.Community, # Базовая таблица community.Community, # Базовая таблица
topic.Topic, # Базовая таблица topic.Topic, # Базовая таблица
# Связи для базовых таблиц # Связи для базовых таблиц
author.AuthorFollower, # Зависит от Author author.AuthorFollower, # Зависит от Author
community.CommunityFollower, # Зависит от Community community.CommunityFollower, # Зависит от Community
topic.TopicFollower, # Зависит от Topic topic.TopicFollower, # Зависит от Topic
# Черновики (теперь без зависимости от Shout) # Черновики (теперь без зависимости от Shout)
draft.Draft, # Зависит только от Author draft.Draft, # Зависит только от Author
draft.DraftAuthor, # Зависит от Draft и Author draft.DraftAuthor, # Зависит от Draft и Author
draft.DraftTopic, # Зависит от Draft и Topic draft.DraftTopic, # Зависит от Draft и Topic
# Основные таблицы контента # Основные таблицы контента
shout.Shout, # Зависит от Author и Draft shout.Shout, # Зависит от Author и Draft
shout.ShoutAuthor, # Зависит от Shout и Author shout.ShoutAuthor, # Зависит от Shout и Author
shout.ShoutTopic, # Зависит от Shout и Topic shout.ShoutTopic, # Зависит от Shout и Topic
# Реакции # Реакции
reaction.Reaction, # Зависит от Author и Shout reaction.Reaction, # Зависит от Author и Shout
shout.ShoutReactionsFollower, # Зависит от Shout и Reaction shout.ShoutReactionsFollower, # Зависит от Shout и Reaction
# Дополнительные таблицы # Дополнительные таблицы
author.AuthorRating, # Зависит от Author author.AuthorRating, # Зависит от Author
notification.Notification, # Зависит от Author notification.Notification, # Зависит от Author
@ -87,7 +81,7 @@ def create_all_tables():
for model in models_in_order: for model in models_in_order:
try: try:
create_table_if_not_exists(session.get_bind(), model) create_table_if_not_exists(session.get_bind(), model)
logger.info(f"Created or verified table: {model.__tablename__}") # logger.info(f"Created or verified table: {model.__tablename__}")
except Exception as e: except Exception as e:
logger.error(f"Error creating table {model.__tablename__}: {e}") logger.error(f"Error creating table {model.__tablename__}: {e}")
raise raise

View File

@ -7,7 +7,12 @@ from typing import Dict
# ga # ga
from google.analytics.data_v1beta import BetaAnalyticsDataClient from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import DateRange, Dimension, Metric, RunReportRequest from google.analytics.data_v1beta.types import (
DateRange,
Dimension,
Metric,
RunReportRequest,
)
from google.analytics.data_v1beta.types import Filter as GAFilter from google.analytics.data_v1beta.types import Filter as GAFilter
from orm.author import Author from orm.author import Author

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import os import os
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -13,6 +14,7 @@ from settings import DB_URL
# Use SQLite for testing # Use SQLite for testing
TEST_DB_URL = "sqlite:///test.db" TEST_DB_URL = "sqlite:///test.db"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def event_loop(): def event_loop():
"""Create an instance of the default event loop for the test session.""" """Create an instance of the default event loop for the test session."""
@ -20,6 +22,7 @@ def event_loop():
yield loop yield loop
loop.close() loop.close()
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def test_engine(): def test_engine():
"""Create a test database engine.""" """Create a test database engine."""
@ -29,6 +32,7 @@ def test_engine():
Base.metadata.drop_all(engine) Base.metadata.drop_all(engine)
os.remove("test.db") os.remove("test.db")
@pytest.fixture @pytest.fixture
def db_session(test_engine): def db_session(test_engine):
"""Create a new database session for a test.""" """Create a new database session for a test."""
@ -42,6 +46,7 @@ def db_session(test_engine):
transaction.rollback() transaction.rollback()
connection.close() connection.close()
@pytest.fixture @pytest.fixture
async def redis_client(): async def redis_client():
"""Create a test Redis client.""" """Create a test Redis client."""
@ -49,6 +54,7 @@ async def redis_client():
yield redis yield redis
await redis.disconnect() await redis.disconnect()
@pytest.fixture @pytest.fixture
def test_client(): def test_client():
"""Create a TestClient instance.""" """Create a TestClient instance."""

View File

@ -1,19 +1,18 @@
import pytest import pytest
from orm.shout import Shout
from orm.author import Author from orm.author import Author
from orm.shout import Shout
@pytest.fixture @pytest.fixture
def test_author(db_session): def test_author(db_session):
"""Create a test author.""" """Create a test author."""
author = Author( author = Author(name="Test Author", slug="test-author", user="test-user-id")
name="Test Author",
slug="test-author",
user="test-user-id"
)
db_session.add(author) db_session.add(author)
db_session.commit() db_session.commit()
return author return author
@pytest.fixture @pytest.fixture
def test_shout(db_session): def test_shout(db_session):
"""Create test shout with required fields.""" """Create test shout with required fields."""
@ -27,12 +26,13 @@ def test_shout(db_session):
created_by=author.id, # Обязательное поле created_by=author.id, # Обязательное поле
body="Test body", body="Test body",
layout="article", layout="article",
lang="ru" lang="ru",
) )
db_session.add(shout) db_session.add(shout)
db_session.commit() db_session.commit()
return shout return shout
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_shout(test_client, db_session, test_author): async def test_create_shout(test_client, db_session, test_author):
"""Test creating a new shout.""" """Test creating a new shout."""
@ -40,8 +40,8 @@ async def test_create_shout(test_client, db_session, test_author):
"/", "/",
json={ json={
"query": """ "query": """
mutation CreateDraft($input: DraftInput!) { mutation CreateDraft($draft_input: DraftInput!) {
create_draft(input: $input) { create_draft(draft_input: $draft_input) {
error error
draft { draft {
id id
@ -56,8 +56,8 @@ async def test_create_shout(test_client, db_session, test_author):
"title": "Test Shout", "title": "Test Shout",
"body": "This is a test shout", "body": "This is a test shout",
} }
} },
} },
) )
assert response.status_code == 200 assert response.status_code == 200
@ -65,6 +65,7 @@ async def test_create_shout(test_client, db_session, test_author):
assert "errors" not in data assert "errors" not in data
assert data["data"]["create_draft"]["draft"]["title"] == "Test Shout" assert data["data"]["create_draft"]["draft"]["title"] == "Test Shout"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_load_drafts(test_client, db_session): async def test_load_drafts(test_client, db_session):
"""Test retrieving a shout.""" """Test retrieving a shout."""
@ -83,10 +84,8 @@ async def test_load_drafts(test_client, db_session):
} }
} }
""", """,
"variables": { "variables": {"slug": "test-shout"},
"slug": "test-shout" },
}
}
) )
assert response.status_code == 200 assert response.status_code == 200

View File

@ -1,8 +1,11 @@
from datetime import datetime
import pytest import pytest
from orm.author import Author
from orm.reaction import Reaction, ReactionKind from orm.reaction import Reaction, ReactionKind
from orm.shout import Shout from orm.shout import Shout
from orm.author import Author
from datetime import datetime
@pytest.fixture @pytest.fixture
def test_setup(db_session): def test_setup(db_session):
@ -21,12 +24,13 @@ def test_setup(db_session):
lang="ru", lang="ru",
community=1, community=1,
created_at=now, created_at=now,
updated_at=now updated_at=now,
) )
db_session.add_all([author, shout]) db_session.add_all([author, shout])
db_session.commit() db_session.commit()
return {"author": author, "shout": shout} return {"author": author, "shout": shout}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_reaction(test_client, db_session, test_setup): async def test_create_reaction(test_client, db_session, test_setup):
"""Test creating a reaction on a shout.""" """Test creating a reaction on a shout."""
@ -49,13 +53,9 @@ async def test_create_reaction(test_client, db_session, test_setup):
} }
""", """,
"variables": { "variables": {
"reaction": { "reaction": {"shout": test_setup["shout"].id, "kind": ReactionKind.LIKE.value, "body": "Great post!"}
"shout": test_setup["shout"].id, },
"kind": ReactionKind.LIKE.value, },
"body": "Great post!"
}
}
}
) )
assert response.status_code == 200 assert response.status_code == 200

View File

@ -1,7 +1,10 @@
from datetime import datetime
import pytest import pytest
from orm.author import Author from orm.author import Author
from orm.shout import Shout from orm.shout import Shout
from datetime import datetime
@pytest.fixture @pytest.fixture
def test_shout(db_session): def test_shout(db_session):
@ -22,12 +25,13 @@ def test_shout(db_session):
lang="ru", lang="ru",
community=1, community=1,
created_at=now, created_at=now,
updated_at=now updated_at=now,
) )
db_session.add(shout) db_session.add(shout)
db_session.commit() db_session.commit()
return shout return shout
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_shout(test_client, db_session): async def test_get_shout(test_client, db_session):
"""Test retrieving a shout.""" """Test retrieving a shout."""
@ -47,7 +51,7 @@ async def test_get_shout(test_client, db_session):
lang="ru", lang="ru",
community=1, community=1,
created_at=now, created_at=now,
updated_at=now updated_at=now,
) )
db_session.add(shout) db_session.add(shout)
db_session.commit() db_session.commit()
@ -71,10 +75,8 @@ async def test_get_shout(test_client, db_session):
} }
} }
""", """,
"variables": { "variables": {"slug": "test-shout"},
"slug": "test-shout" },
}
}
) )
data = response.json() data = response.json()

View File

@ -1,25 +1,23 @@
import pytest
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytest
from pydantic import ValidationError from pydantic import ValidationError
from auth.validations import ( from auth.validations import (
AuthInput, AuthInput,
UserRegistrationInput, AuthResponse,
UserLoginInput,
TokenPayload,
OAuthInput, OAuthInput,
AuthResponse TokenPayload,
UserLoginInput,
UserRegistrationInput,
) )
class TestAuthValidations: class TestAuthValidations:
def test_auth_input(self): def test_auth_input(self):
"""Test basic auth input validation""" """Test basic auth input validation"""
# Valid case # Valid case
auth = AuthInput( auth = AuthInput(user_id="123", username="testuser", token="1234567890abcdef1234567890abcdef")
user_id="123",
username="testuser",
token="1234567890abcdef1234567890abcdef"
)
assert auth.user_id == "123" assert auth.user_id == "123"
assert auth.username == "testuser" assert auth.username == "testuser"
@ -33,30 +31,18 @@ class TestAuthValidations:
def test_user_registration(self): def test_user_registration(self):
"""Test user registration validation""" """Test user registration validation"""
# Valid case # Valid case
user = UserRegistrationInput( user = UserRegistrationInput(email="test@example.com", password="SecurePass123!", name="Test User")
email="test@example.com",
password="SecurePass123!",
name="Test User"
)
assert user.email == "test@example.com" assert user.email == "test@example.com"
assert user.name == "Test User" assert user.name == "Test User"
# Test email validation # Test email validation
with pytest.raises(ValidationError) as exc: with pytest.raises(ValidationError) as exc:
UserRegistrationInput( UserRegistrationInput(email="invalid-email", password="SecurePass123!", name="Test")
email="invalid-email",
password="SecurePass123!",
name="Test"
)
assert "Invalid email format" in str(exc.value) assert "Invalid email format" in str(exc.value)
# Test password validation # Test password validation
with pytest.raises(ValidationError) as exc: with pytest.raises(ValidationError) as exc:
UserRegistrationInput( UserRegistrationInput(email="test@example.com", password="weak", name="Test")
email="test@example.com",
password="weak",
name="Test"
)
assert "String should have at least 8 characters" in str(exc.value) assert "String should have at least 8 characters" in str(exc.value)
def test_token_payload(self): def test_token_payload(self):
@ -64,12 +50,7 @@ class TestAuthValidations:
now = datetime.utcnow() now = datetime.utcnow()
exp = now + timedelta(hours=1) exp = now + timedelta(hours=1)
payload = TokenPayload( payload = TokenPayload(user_id="123", username="testuser", exp=exp, iat=now)
user_id="123",
username="testuser",
exp=exp,
iat=now
)
assert payload.user_id == "123" assert payload.user_id == "123"
assert payload.username == "testuser" assert payload.username == "testuser"
assert payload.scopes == [] # Default empty list assert payload.scopes == [] # Default empty list
@ -77,25 +58,15 @@ class TestAuthValidations:
def test_auth_response(self): def test_auth_response(self):
"""Test auth response validation""" """Test auth response validation"""
# Success case # Success case
success_resp = AuthResponse( success_resp = AuthResponse(success=True, token="valid_token", user={"id": "123", "name": "Test"})
success=True,
token="valid_token",
user={"id": "123", "name": "Test"}
)
assert success_resp.success is True assert success_resp.success is True
assert success_resp.token == "valid_token" assert success_resp.token == "valid_token"
# Error case # Error case
error_resp = AuthResponse( error_resp = AuthResponse(success=False, error="Invalid credentials")
success=False,
error="Invalid credentials"
)
assert error_resp.success is False assert error_resp.success is False
assert error_resp.error == "Invalid credentials" assert error_resp.error == "Invalid credentials"
# Invalid case - отсутствует обязательное поле token при success=True # Invalid case - отсутствует обязательное поле token при success=True
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
AuthResponse( AuthResponse(success=True, user={"id": "123", "name": "Test"})
success=True,
user={"id": "123", "name": "Test"}
)

View File

@ -6,17 +6,26 @@ import colorlog
_lib_path = Path(__file__).parents[1] _lib_path = Path(__file__).parents[1]
_leng_path = len(_lib_path.as_posix()) _leng_path = len(_lib_path.as_posix())
def filter(record: logging.LogRecord): def filter(record: logging.LogRecord):
# Define `package` attribute with the relative path. # Define `package` attribute with the relative path.
record.package = record.pathname[_leng_path + 1 :].replace(".py", "") record.package = record.pathname[_leng_path + 1 :].replace(".py", "")
record.emoji = "🔍" if record.levelno == logging.DEBUG \ record.emoji = (
else "🖊️" if record.levelno == logging.INFO \ "🔍"
else "🚧" if record.levelno == logging.WARNING \ if record.levelno == logging.DEBUG
else "" if record.levelno == logging.ERROR \ else "🖊️"
else "🧨" if record.levelno == logging.CRITICAL \ if record.levelno == logging.INFO
else "🚧"
if record.levelno == logging.WARNING
else ""
if record.levelno == logging.ERROR
else "🧨"
if record.levelno == logging.CRITICAL
else "" else ""
)
return record return record
# Define the color scheme # Define the color scheme
color_scheme = { color_scheme = {
"DEBUG": "light_black", "DEBUG": "light_black",
@ -55,7 +64,7 @@ class MultilineColoredFormatter(colorlog.ColoredFormatter):
def format(self, record): def format(self, record):
# Add default emoji if not present # Add default emoji if not present
if not hasattr(record, 'emoji'): if not hasattr(record, "emoji"):
record = filter(record) record = filter(record)
message = record.getMessage() message = record.getMessage()