Compare commits

...

6 Commits

Author SHA1 Message Date
a5fccd0710 feat: AppointmentsPage: Добавлено отображение напоминаний 2025-07-03 13:32:00 +05:00
59b77a665b feat(profile): Добавлена поддержка управления сессиями 2025-07-03 13:15:56 +05:00
dc47e4b003 feat: auth Добавлена поддержка сессий пользователей
Добавлена функциональность управления сессиями пользователей, включая создание сессий при входе, получение списка активных сессий, деактивацию отдельных сессий и деактивацию всех сессий пользователя.
2025-07-03 09:16:19 +05:00
89bfb07a23 feat: Бэкапы: Удалено восстановление бэкапов. 2025-07-03 08:45:22 +05:00
8042460557 feat: Добавлена функция восстановления бэкапов
Внесены изменения в API и веб-приложение для поддержки восстановления резервных копий данных. Добавлена новая мутация API для восстановления бэкапов. Добавлена кнопка восстановления в веб-приложении. Добавлена функция прерывания всех запросов при восстановлении бэкапа.
2025-07-03 08:38:26 +05:00
04b9682f1e refactor(backup): Изменено API скачивания бэкапов 2025-07-02 11:59:53 +05:00
47 changed files with 853 additions and 228 deletions

View File

@ -2,7 +2,7 @@ from typing import Sequence, Optional
from sqlalchemy import select, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from datetime import date
from datetime import date, timedelta
from app.domain.models import Appointment
@ -43,6 +43,35 @@ class AppointmentsRepository:
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_reminders(self, current_date: date) -> Sequence[Appointment]:
stmt = (
select(Appointment)
.options(joinedload(Appointment.type))
.options(joinedload(Appointment.patient))
.options(joinedload(Appointment.doctor))
.filter(Appointment.days_until_the_next_appointment.is_not(None))
)
result = await self.db.execute(stmt)
appointments = result.scalars().all()
filtered_appointments = []
for appointment in appointments:
next_appointment_date = (appointment.appointment_datetime + timedelta(
days=appointment.days_until_the_next_appointment)).date()
days_until = appointment.days_until_the_next_appointment
window_days = (
7 if days_until <= 90 else
14 if days_until <= 180 else
30 if days_until <= 365 else
60
)
window_start = next_appointment_date - timedelta(days=window_days)
window_end = next_appointment_date + timedelta(days=window_days)
if window_start <= current_date <= window_end:
filtered_appointments.append(appointment)
return filtered_appointments
async def get_upcoming_by_doctor_id(self, doctor_id: int) -> Sequence[Appointment]:
stmt = (
select(Appointment)

View File

@ -0,0 +1,55 @@
import datetime
from typing import Optional, Sequence
from sqlalchemy import update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.domain.models.sessions import Session
class SessionsRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, session: Session) -> Session:
self.db.add(session)
await self.db.commit()
await self.db.refresh(session)
return session
async def get_by_id(self, session_id: int) -> Optional[Session]:
result = await self.db.execute(
select(Session).filter_by(id=session_id)
)
return result.scalars().first()
async def get_by_token(self, token: str) -> Optional[Session]:
result = await self.db.execute(
select(Session).filter_by(token=token, is_active=True)
)
return result.scalars().first()
async def get_by_user_id(self, user_id: int) -> Sequence[Session]:
result = await self.db.execute(
select(Session).filter_by(user_id=user_id, is_active=True)
)
return result.scalars().all()
async def deactivate_session(self, session_id: int) -> None:
await self.db.execute(
update(Session).filter_by(id=session_id).values(is_active=False)
)
await self.db.commit()
async def deactivate_all_sessions(self, user_id: int) -> None:
await self.db.execute(
update(Session).filter_by(user_id=user_id).values(is_active=False)
)
await self.db.commit()
async def cleanup_expired_sessions(self) -> None:
await self.db.execute(
delete(Session).filter(Session.expires_at < datetime.datetime.now())
)
await self.db.commit()

View File

@ -58,6 +58,22 @@ async def get_upcoming_appointments_by_doctor_id(
return await appointments_service.get_upcoming_appointments_by_doctor_id(doctor_id)
@router.get(
"/reminders/",
response_model=list[AppointmentEntity],
summary="Get appointment reminders",
description="Returns a list of appointments with upcoming follow-up reminders based on days_until_the_next_appointment",
)
async def get_appointment_reminders(
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
current_date: date = Query(default_factory=date.today,
description="Current date for reminder calculation (YYYY-MM-DD)"),
):
appointments_service = AppointmentsService(db)
return await appointments_service.get_appointment_reminders(current_date)
@router.get(
"/patient/{patient_id}/",
response_model=list[AppointmentEntity],

View File

@ -1,10 +1,14 @@
from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, Response, Request
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app.database.session import get_db
from app.domain.entities.auth import AuthEntity
from app.domain.entities.responses.session import SessionEntity
from app.domain.entities.token_entity import TokenEntity
from app.infrastructure.auth_service import AuthService
from app.infrastructure.dependencies import get_current_user
from app.domain.models.users import User
router = APIRouter()
@ -19,10 +23,11 @@ router = APIRouter()
async def auth_user(
response: Response,
user_data: AuthEntity,
request: Request,
db: AsyncSession = Depends(get_db)
):
auth_service = AuthService(db)
token = await auth_service.authenticate_user(user_data.login, user_data.password)
token = await auth_service.authenticate_user(user_data.login, user_data.password, request)
response.set_cookie(
key="users_access_token",
@ -32,3 +37,46 @@ async def auth_user(
)
return token
@router.get(
"/sessions/",
response_model=List[SessionEntity],
summary="Get user sessions",
description="Returns a list of active sessions for the current user",
)
async def get_sessions(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_service = AuthService(db)
return await auth_service.get_user_sessions(user.id)
@router.post(
"/sessions/{session_id}/logout/",
summary="Log out from a specific session",
description="Deactivates a specific session by ID",
)
async def logout_session(
session_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_service = AuthService(db)
await auth_service.deactivate_session(session_id, user.id)
return {"message": "Session deactivated"}
@router.post(
"/sessions/logout_all/",
summary="Log out from all sessions",
description="Deactivates all sessions for the current user",
)
async def logout_all_sessions(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_service = AuthService(db)
await auth_service.deactivate_all_sessions(user.id)
return {"message": "All sessions deactivated"}

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, File, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from app.database.session import get_db
from app.database.session import get_db, engine
from app.domain.entities.responses.backup import BackupResponseEntity
from app.infrastructure.backup_service import BackupService
from app.infrastructure.dependencies import require_admin
@ -26,8 +26,8 @@ async def get_all_backups(
@router.get(
'/{backup_id}/',
response_model=BackupResponseEntity,
'/{backup_id}/file/',
response_class=FileResponse,
summary='Get a backup file',
description='Get a backup file',
)

View File

@ -0,0 +1,123 @@
"""0008 добавил тпблицу сессий
Revision ID: b013393cef10
Revises: 4f3877d7a2b1
Create Date: 2025-07-03 09:05:56.233095
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b013393cef10'
down_revision: Union[str, None] = '4f3877d7a2b1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sessions',
sa.Column('token', sa.String(), nullable=False),
sa.Column('device_info', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['public.users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token'),
schema='public'
)
op.drop_constraint('appointment_files_appointment_id_fkey', 'appointment_files', type_='foreignkey')
op.create_foreign_key(None, 'appointment_files', 'appointments', ['appointment_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('appointments_patient_id_fkey', 'appointments', type_='foreignkey')
op.drop_constraint('appointments_doctor_id_fkey', 'appointments', type_='foreignkey')
op.drop_constraint('appointments_type_id_fkey', 'appointments', type_='foreignkey')
op.create_foreign_key(None, 'appointments', 'patients', ['patient_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'appointments', 'users', ['doctor_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'appointments', 'appointment_types', ['type_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('backups_user_id_fkey', 'backups', type_='foreignkey')
op.create_foreign_key(None, 'backups', 'users', ['user_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('lens_type_id_fkey', 'lens', type_='foreignkey')
op.create_foreign_key(None, 'lens', 'lens_types', ['type_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('lens_issues_doctor_id_fkey', 'lens_issues', type_='foreignkey')
op.drop_constraint('lens_issues_patient_id_fkey', 'lens_issues', type_='foreignkey')
op.drop_constraint('lens_issues_lens_id_fkey', 'lens_issues', type_='foreignkey')
op.create_foreign_key(None, 'lens_issues', 'patients', ['patient_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'lens_issues', 'lens', ['lens_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'lens_issues', 'users', ['doctor_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('mailing_user_id_fkey', 'mailing', type_='foreignkey')
op.create_foreign_key(None, 'mailing', 'users', ['user_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('mailing_options_option_id_fkey', 'mailing_options', type_='foreignkey')
op.drop_constraint('mailing_options_mailing_id_fkey', 'mailing_options', type_='foreignkey')
op.create_foreign_key(None, 'mailing_options', 'mailing', ['mailing_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'mailing_options', 'mailing_delivery_methods', ['option_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('recipients_mailing_id_fkey', 'recipients', type_='foreignkey')
op.drop_constraint('recipients_patient_id_fkey', 'recipients', type_='foreignkey')
op.create_foreign_key(None, 'recipients', 'patients', ['patient_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'recipients', 'mailing', ['mailing_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('scheduled_appointments_type_id_fkey', 'scheduled_appointments', type_='foreignkey')
op.drop_constraint('scheduled_appointments_doctor_id_fkey', 'scheduled_appointments', type_='foreignkey')
op.drop_constraint('scheduled_appointments_patient_id_fkey', 'scheduled_appointments', type_='foreignkey')
op.create_foreign_key(None, 'scheduled_appointments', 'appointment_types', ['type_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'scheduled_appointments', 'patients', ['patient_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'scheduled_appointments', 'users', ['doctor_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('set_contents_type_id_fkey', 'set_contents', type_='foreignkey')
op.drop_constraint('set_contents_set_id_fkey', 'set_contents', type_='foreignkey')
op.create_foreign_key(None, 'set_contents', 'sets', ['set_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'set_contents', 'lens_types', ['type_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('users_role_id_fkey', 'users', type_='foreignkey')
op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', schema='public', type_='foreignkey')
op.create_foreign_key('users_role_id_fkey', 'users', 'roles', ['role_id'], ['id'])
op.drop_constraint(None, 'set_contents', schema='public', type_='foreignkey')
op.drop_constraint(None, 'set_contents', schema='public', type_='foreignkey')
op.create_foreign_key('set_contents_set_id_fkey', 'set_contents', 'sets', ['set_id'], ['id'])
op.create_foreign_key('set_contents_type_id_fkey', 'set_contents', 'lens_types', ['type_id'], ['id'])
op.drop_constraint(None, 'scheduled_appointments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'scheduled_appointments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'scheduled_appointments', schema='public', type_='foreignkey')
op.create_foreign_key('scheduled_appointments_patient_id_fkey', 'scheduled_appointments', 'patients', ['patient_id'], ['id'])
op.create_foreign_key('scheduled_appointments_doctor_id_fkey', 'scheduled_appointments', 'users', ['doctor_id'], ['id'])
op.create_foreign_key('scheduled_appointments_type_id_fkey', 'scheduled_appointments', 'appointment_types', ['type_id'], ['id'])
op.drop_constraint(None, 'recipients', schema='public', type_='foreignkey')
op.drop_constraint(None, 'recipients', schema='public', type_='foreignkey')
op.create_foreign_key('recipients_patient_id_fkey', 'recipients', 'patients', ['patient_id'], ['id'])
op.create_foreign_key('recipients_mailing_id_fkey', 'recipients', 'mailing', ['mailing_id'], ['id'])
op.drop_constraint(None, 'mailing_options', schema='public', type_='foreignkey')
op.drop_constraint(None, 'mailing_options', schema='public', type_='foreignkey')
op.create_foreign_key('mailing_options_mailing_id_fkey', 'mailing_options', 'mailing', ['mailing_id'], ['id'])
op.create_foreign_key('mailing_options_option_id_fkey', 'mailing_options', 'mailing_delivery_methods', ['option_id'], ['id'])
op.drop_constraint(None, 'mailing', schema='public', type_='foreignkey')
op.create_foreign_key('mailing_user_id_fkey', 'mailing', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'lens_issues', schema='public', type_='foreignkey')
op.drop_constraint(None, 'lens_issues', schema='public', type_='foreignkey')
op.drop_constraint(None, 'lens_issues', schema='public', type_='foreignkey')
op.create_foreign_key('lens_issues_lens_id_fkey', 'lens_issues', 'lens', ['lens_id'], ['id'])
op.create_foreign_key('lens_issues_patient_id_fkey', 'lens_issues', 'patients', ['patient_id'], ['id'])
op.create_foreign_key('lens_issues_doctor_id_fkey', 'lens_issues', 'users', ['doctor_id'], ['id'])
op.drop_constraint(None, 'lens', schema='public', type_='foreignkey')
op.create_foreign_key('lens_type_id_fkey', 'lens', 'lens_types', ['type_id'], ['id'])
op.drop_constraint(None, 'backups', schema='public', type_='foreignkey')
op.create_foreign_key('backups_user_id_fkey', 'backups', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'appointments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'appointments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'appointments', schema='public', type_='foreignkey')
op.create_foreign_key('appointments_type_id_fkey', 'appointments', 'appointment_types', ['type_id'], ['id'])
op.create_foreign_key('appointments_doctor_id_fkey', 'appointments', 'users', ['doctor_id'], ['id'])
op.create_foreign_key('appointments_patient_id_fkey', 'appointments', 'patients', ['patient_id'], ['id'])
op.drop_constraint(None, 'appointment_files', schema='public', type_='foreignkey')
op.create_foreign_key('appointment_files_appointment_id_fkey', 'appointment_files', 'appointments', ['appointment_id'], ['id'])
op.drop_table('sessions', schema='public')
# ### end Alembic commands ###

View File

@ -0,0 +1,16 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class SessionEntity(BaseModel):
id: int
user_id: int
token: str
device_info: Optional[str]
created_at: datetime
expires_at: datetime
is_active: bool
class Config:
from_attributes = True

View File

@ -16,6 +16,7 @@ from app.domain.models.patients import Patient
from app.domain.models.recipients import Recipient
from app.domain.models.roles import Role
from app.domain.models.scheduled_appointments import ScheduledAppointment
from app.domain.models.sessions import Session
from app.domain.models.set_contents import SetContent
from app.domain.models.sets import Set
from app.domain.models.users import User

View File

@ -2,14 +2,16 @@ from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class AppointmentFile(BaseModel):
__tablename__ = 'appointment_files'
__table_args__ = {"schema": settings.SCHEMA}
file_path = Column(String, nullable=False)
file_title = Column(String, nullable=False)
appointment_id = Column(Integer, ForeignKey('appointments.id'), nullable=False)
appointment_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.appointments.id'), nullable=False)
appointment = relationship('Appointment', back_populates='files')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, VARCHAR
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class AppointmentType(BaseModel):
__tablename__ = 'appointment_types'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(150), nullable=False, unique=True)

View File

@ -3,18 +3,20 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.domain.models.base import BaseModel
from app.settings import settings
class Appointment(BaseModel):
__tablename__ = 'appointments'
__table_args__ = {"schema": settings.SCHEMA}
results = Column(String)
days_until_the_next_appointment = Column(Integer)
appointment_datetime = Column(DateTime, nullable=False, server_default=func.now())
patient_id = Column(Integer, ForeignKey('patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey('users.id'), nullable=False)
type_id = Column(Integer, ForeignKey('appointment_types.id'), nullable=False)
patient_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
type_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.appointment_types.id'), nullable=False)
patient = relationship('Patient', back_populates='appointments')
doctor = relationship('User', back_populates='appointments')

View File

@ -3,14 +3,16 @@ import datetime
from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, Boolean
from app.domain.models.base import BaseModel
from app.settings import settings
class Backup(BaseModel):
__tablename__ = 'backups'
__table_args__ = {"schema": settings.SCHEMA}
timestamp = Column(DateTime, nullable=False, default=datetime.datetime.now)
path = Column(String, nullable=False)
filename = Column(String, nullable=False)
is_by_user = Column(Boolean, nullable=False, default=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
user_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)

View File

@ -4,6 +4,7 @@ from sqlalchemy import Column, Integer, ForeignKey, Float, Enum, Boolean
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class SideEnum(PyEnum):
@ -13,6 +14,7 @@ class SideEnum(PyEnum):
class Lens(BaseModel):
__tablename__ = 'lens'
__table_args__ = {"schema": settings.SCHEMA}
tor = Column(Float, nullable=False)
trial = Column(Float, nullable=False)
@ -24,7 +26,7 @@ class Lens(BaseModel):
side = Column(Enum(SideEnum), nullable=False)
issued = Column(Boolean, nullable=False, default=False)
type_id = Column(Integer, ForeignKey('lens_types.id'), nullable=False)
type_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.lens_types.id'), nullable=False)
type = relationship('LensType', back_populates='lenses')

View File

@ -2,16 +2,18 @@ from sqlalchemy import Column, Integer, ForeignKey, Date
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class LensIssue(BaseModel):
__tablename__ = 'lens_issues'
__table_args__ = {"schema": settings.SCHEMA}
issue_date = Column(Date, nullable=False)
patient_id = Column(Integer, ForeignKey('patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey('users.id'), nullable=False)
lens_id = Column(Integer, ForeignKey('lens.id'), nullable=False)
patient_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
lens_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.lens.id'), nullable=False)
patient = relationship('Patient', back_populates='lens_issues')
doctor = relationship('User', back_populates='lens_issues')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, Integer, VARCHAR
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class LensType(BaseModel):
__tablename__ = 'lens_types'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(150), nullable=False, unique=True)

View File

@ -3,16 +3,18 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.domain.models.base import BaseModel
from app.settings import settings
class Mailing(BaseModel):
__tablename__ = 'mailing'
__table_args__ = {"schema": settings.SCHEMA}
text = Column(String, nullable=False)
title = Column(String, nullable=False)
datetime = Column(DateTime, nullable=False, default=func.utcnow)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
user_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
user = relationship('User', back_populates='mailing')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, Integer, VARCHAR
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class MailingDeliveryMethod(BaseModel):
__tablename__ = 'mailing_delivery_methods'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(200), nullable=False)

View File

@ -2,13 +2,15 @@ from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class MailingOption(BaseModel):
__tablename__ = 'mailing_options'
__table_args__ = {"schema": settings.SCHEMA}
option_id = Column(Integer, ForeignKey('mailing_delivery_methods.id'), nullable=False)
mailing_id = Column(Integer, ForeignKey('mailing.id'), nullable=False)
option_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.mailing_delivery_methods.id'), nullable=False)
mailing_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.mailing.id'), nullable=False)
method = relationship('MailingDeliveryMethod', back_populates='mailing')
mailing = relationship('Mailing', back_populates='mailing_options')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, VARCHAR, Date, String
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class Patient(BaseModel):
__tablename__ = 'patients'
__table_args__ = {"schema": settings.SCHEMA}
first_name = Column(VARCHAR(200), nullable=False)
last_name = Column(VARCHAR(200), nullable=False)

View File

@ -2,13 +2,15 @@ from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class Recipient(BaseModel):
__tablename__ = 'recipients'
__table_args__ = {"schema": settings.SCHEMA}
patient_id = Column(Integer, ForeignKey('patients.id'), nullable=False)
mailing_id = Column(Integer, ForeignKey('mailing.id'), nullable=False)
patient_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.patients.id'), nullable=False)
mailing_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.mailing.id'), nullable=False)
patient = relationship('Patient', back_populates='mailing')
mailing = relationship('Mailing', back_populates='recipients')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, VARCHAR
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class Role(BaseModel):
__tablename__ = 'roles'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(150), nullable=False, unique=True)

View File

@ -3,17 +3,19 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.domain.models.base import BaseModel
from app.settings import settings
class ScheduledAppointment(BaseModel):
__tablename__ = 'scheduled_appointments'
__table_args__ = {"schema": settings.SCHEMA}
scheduled_datetime = Column(DateTime, nullable=False, server_default=func.now())
is_canceled = Column(Boolean, nullable=False, default=False, server_default='false')
patient_id = Column(Integer, ForeignKey('patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey('users.id'), nullable=False)
type_id = Column(Integer, ForeignKey('appointment_types.id'), nullable=False)
patient_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
type_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.appointment_types.id'), nullable=False)
patient = relationship('Patient', back_populates='scheduled_appointments')
doctor = relationship('User', back_populates='scheduled_appointments')

View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
from app.domain.models.base import BaseModel
from app.settings import settings
class Session(BaseModel):
__tablename__ = 'sessions'
__table_args__ = {"schema": settings.SCHEMA}
token = Column(String, nullable=False, unique=True)
device_info = Column(String, nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
user_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
user = relationship("User", back_populates="sessions")

View File

@ -3,10 +3,12 @@ from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.domain.models.lens import SideEnum
from app.settings import settings
class SetContent(BaseModel):
__tablename__ = 'set_contents'
__table_args__ = {"schema": settings.SCHEMA}
tor = Column(Float, nullable=False)
trial = Column(Float, nullable=False)
@ -18,8 +20,8 @@ class SetContent(BaseModel):
side = Column(Enum(SideEnum), nullable=False)
count = Column(Integer, nullable=False)
type_id = Column(Integer, ForeignKey('lens_types.id'), nullable=False)
set_id = Column(Integer, ForeignKey('sets.id'), nullable=False)
type_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.lens_types.id'), nullable=False)
set_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.sets.id'), nullable=False)
type = relationship('LensType', back_populates='contents')
set = relationship('Set', back_populates='contents')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, Integer, VARCHAR
from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel
from app.settings import settings
class Set(BaseModel):
__tablename__ = 'sets'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(150), nullable=False, unique=True)

View File

@ -3,10 +3,12 @@ from sqlalchemy.orm import relationship
from werkzeug.security import check_password_hash, generate_password_hash
from app.domain.models.base import BaseModel
from app.settings import settings
class User(BaseModel):
__tablename__ = 'users'
__table_args__ = {"schema": settings.SCHEMA}
first_name = Column(VARCHAR(200), nullable=False)
last_name = Column(VARCHAR(200), nullable=False)
@ -15,7 +17,7 @@ class User(BaseModel):
password = Column(String, nullable=False)
is_blocked = Column(Boolean, nullable=False, default=False, server_default='false')
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False)
role_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.roles.id'), nullable=False)
role = relationship('Role', back_populates='users')
@ -23,6 +25,7 @@ class User(BaseModel):
appointments = relationship('Appointment', back_populates='doctor')
mailing = relationship('Mailing', back_populates='user')
scheduled_appointments = relationship('ScheduledAppointment', back_populates='doctor')
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
def check_password(self, password):
return check_password_hash(self.password, password)

View File

@ -49,6 +49,10 @@ class AppointmentsService:
appointments = await self.appointments_repository.get_upcoming_by_doctor_id(doctor_id)
return [self.model_to_entity(appointment) for appointment in appointments]
async def get_appointment_reminders(self, current_date: date) -> list[AppointmentEntity]:
appointments = await self.appointments_repository.get_reminders(current_date)
return [self.model_to_entity(appointment) for appointment in appointments]
async def get_appointments_by_patient_id(self, patient_id: int, start_date: date | None = None,
end_date: date | None = None) -> Optional[list[AppointmentEntity]]:
patient = await self.patients_repository.get_by_id(patient_id)

View File

@ -1,23 +1,37 @@
import datetime
from typing import Optional
from typing import Optional, List
import jwt
from fastapi import HTTPException
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from starlette import status
from app.application.users_repository import UsersRepository
from app.application.sessions_repository import SessionsRepository
from app.domain.entities.responses.session import SessionEntity
from app.domain.models.sessions import Session
from app.settings import get_auth_data
class AuthService:
def __init__(self, db: AsyncSession):
self.users_repository = UsersRepository(db)
self.sessions_repository = SessionsRepository(db)
async def authenticate_user(self, login: str, password: str) -> Optional[dict]:
async def authenticate_user(self, login: str, password: str, request: Request) -> Optional[dict]:
user = await self.users_repository.get_by_login(login)
if user and user.check_password(password):
access_token = self.create_access_token({"user_id": user.id})
session = Session(
user_id=user.id,
token=access_token,
device_info=request.headers.get("User-Agent", "Unknown"),
created_at=datetime.datetime.now(), # Naive datetime
expires_at=datetime.datetime.now() + datetime.timedelta(days=30),
is_active=True
)
await self.sessions_repository.create(session)
return {
"access_token": access_token,
"user_id": user.id
@ -25,12 +39,27 @@ class AuthService:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Неправильный логин или пароль")
async def get_user_sessions(self, user_id: int) -> List[SessionEntity]:
sessions = await self.sessions_repository.get_by_user_id(user_id)
return [SessionEntity.from_orm(session) for session in sessions]
async def deactivate_session(self, session_id: int, user_id: int) -> None:
session = await self.sessions_repository.get_by_id(session_id)
if not session or session.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Сессия не найдена или доступ запрещен")
await self.sessions_repository.deactivate_session(session_id)
async def deactivate_all_sessions(self, user_id: int) -> None:
await self.sessions_repository.deactivate_all_sessions(user_id)
async def cleanup_expired_sessions(self) -> None:
await self.sessions_repository.cleanup_expired_sessions()
@staticmethod
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=30)
expire = datetime.datetime.now() + datetime.timedelta(days=30) # Naive datetime
to_encode.update({"exp": expire})
auth_data = get_auth_data()
encode_jwt = jwt.encode(to_encode, auth_data['secret_key'], algorithm=auth_data['algorithm'])
return encode_jwt

View File

@ -1,14 +1,16 @@
import io
import subprocess
import os
import tarfile
import datetime
from typing import Any, Coroutine, Optional
import io
import os
import shutil
import subprocess
import tarfile
from typing import Optional
from fastapi_maintenance import maintenance_mode_on
import aiofiles
from fastapi import HTTPException, UploadFile
from magic import magic
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
from starlette.responses import FileResponse
from werkzeug.utils import secure_filename

View File

@ -8,6 +8,7 @@ from app.database.session import get_db
from app.domain.models.users import User
from app.settings import get_auth_data
from app.application.users_repository import UsersRepository
from app.application.sessions_repository import SessionsRepository
security = HTTPBearer()
@ -29,6 +30,10 @@ async def get_current_user(
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неправильный токен")
session = await SessionsRepository(db).get_by_token(credentials.credentials)
if not session or not session.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Сессия неактивна или не найдена")
user = await UsersRepository(db).get_by_id_with_role(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Пользователь не найден")

View File

@ -1,4 +1,5 @@
from fastapi import FastAPI
from fastapi_maintenance import MaintenanceModeMiddleware
from starlette.middleware.cors import CORSMiddleware
from app.controllers.appointment_files_router import router as appointment_files_router
@ -29,6 +30,7 @@ def start_app():
allow_methods=['*'],
allow_headers=['*'],
)
# api_app.add_middleware(MaintenanceModeMiddleware, enable_maintenance=False)
api_app.include_router(appointment_files_router, prefix=f'{settings.APP_PREFIX}/appointment_files',
tags=['appointment_files'])

View File

@ -12,6 +12,7 @@ class Settings(BaseSettings):
BACKUP_DIR: str = 'backups'
BACKUP_DB_URL: str
PG_DUMP_PATH: str
SCHEMA: str = 'public'
class Config:
env_file = '.env'

View File

@ -11,4 +11,5 @@ Werkzeug==3.1.3
pyjwt==2.10.1
python-magic==0.4.27
aiofiles==24.1.0
python-multipart==0.0.20
python-multipart==0.0.20
fastapi-maintenance==0.0.4

View File

@ -31,13 +31,11 @@ export const appointmentsApi = createApi({
}),
invalidatesTags: ['Appointment'],
}),
updateAppointment: builder.mutation({
query: ({id, data}) => ({
url: `/appointments/${id}/`,
method: 'PUT',
body: data,
getAppointmentReminders: builder.query({
query: (current_date) => ({
url: "/appointments/reminders/",
params: { current_date },
}),
invalidatesTags: ['Appointment'],
}),
}),
});
@ -47,5 +45,5 @@ export const {
useGetUpcomingAppointmentsQuery,
useGetByPatientIdQuery,
useCreateAppointmentMutation,
useUpdateAppointmentMutation,
useGetAppointmentRemindersQuery
} = appointmentsApi;

View File

@ -1,7 +1,6 @@
import {createApi} from "@reduxjs/toolkit/query/react";
import {baseQueryWithAuth} from "./baseQuery.js";
export const authApi = createApi({
reducerPath: "authApi",
baseQuery: baseQueryWithAuth,
@ -13,7 +12,25 @@ export const authApi = createApi({
body: credentials,
}),
}),
getSessions: builder.query({
query: () => ({
url: "/sessions/",
method: "GET",
}),
}),
logoutSession: builder.mutation({
query: (sessionId) => ({
url: `/sessions/${sessionId}/logout/`,
method: "POST",
}),
}),
logoutAllSessions: builder.mutation({
query: () => ({
url: "/sessions/logout_all/",
method: "POST",
}),
}),
}),
});
export const {useLoginMutation} = authApi;
export const {useLoginMutation, useGetSessionsQuery, useLogoutSessionMutation, useLogoutAllSessionsMutation} = authApi;

View File

@ -40,7 +40,6 @@ export const backupsApi = createApi({
},
invalidatesTags: ['Backup'],
}),
}),
});

View File

@ -17,7 +17,7 @@ const AdminRoute = () => {
}
if (isUserError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке данных пользователя"/>;
return <Navigate to="/login"/>;
}
if (!user) {

View File

@ -3,13 +3,13 @@ import {useSelector} from "react-redux";
import LoadingIndicator from "../Components/Widgets/LoadingIndicator/LoadingIndicator.jsx";
const PrivateRoute = () => {
const {user, userData, isLoading} = useSelector((state) => state.auth);
const {user, userData, isLoading, error} = useSelector((state) => state.auth);
if (isLoading) {
return <LoadingIndicator/>;
}
if (!user || !userData || userData.is_blocked) {
if (error || !user || !userData || userData.is_blocked) {
return <Navigate to="/login"/>;
}

View File

@ -1,29 +1,29 @@
import { useDispatch, useSelector } from "react-redux";
import { setSelectedAppointment } from "../../../Redux/Slices/appointmentsSlice.js";
import {useDispatch, useSelector} from "react-redux";
import {setSelectedAppointment} from "../../../Redux/Slices/appointmentsSlice.js";
import dayjs from "dayjs";
import { useState } from "react";
import { useGetAppointmentFilesQuery, useDeleteAppointmentFileMutation } from "../../../Api/appointmentFilesApi.js";
import { baseQueryWithAuth } from "../../../Api/baseQuery.js";
import { notification } from "antd";
import {useState} from "react";
import {useGetAppointmentFilesQuery, useDeleteAppointmentFileMutation} from "../../../Api/appointmentFilesApi.js";
import {notification} from "antd";
import CONFIG from "../../../Core/сonfig.js";
const useAppointmentView = () => {
const dispatch = useDispatch();
const { selectedAppointment } = useSelector((state) => state.appointmentsUI);
const {selectedAppointment} = useSelector((state) => state.appointmentsUI);
const { data: files = [], isLoading: isFilesLoading } = useGetAppointmentFilesQuery(
const {data: files = [], isLoading: isFilesLoading} = useGetAppointmentFilesQuery(
selectedAppointment?.id,
{ skip: !selectedAppointment?.id }
{skip: !selectedAppointment?.id}
);
const [deleteAppointmentFile, { isLoading: isDeletingFile }] = useDeleteAppointmentFileMutation();
const [deleteAppointmentFile, {isLoading: isDeletingFile}] = useDeleteAppointmentFileMutation();
const [downloadingFiles, setDownloadingFiles] = useState({});
const [deletingFiles, setDeletingFiles] = useState({});
const modalWidth = 700;
const blockStyle = { marginBottom: 16 };
const footerRowStyle = { marginTop: 16 };
const footerButtonStyle = { marginRight: 8 };
const blockStyle = {marginBottom: 16};
const footerRowStyle = {marginTop: 16};
const footerButtonStyle = {marginRight: 8};
const labels = {
title: "Просмотр приема",
@ -81,54 +81,84 @@ const useAppointmentView = () => {
const downloadFile = async (fileId, fileName) => {
try {
setDownloadingFiles((prev) => ({ ...prev, [fileId]: true }));
const { url, ...options } = await baseQueryWithAuth(
{
url: `/appointment_files/${fileId}/file/`,
method: 'GET',
credentials: 'include',
},
{},
{}
);
const response = await fetch(url, {
...options,
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
const token = localStorage.getItem('access_token');
if (!token) {
notification.error({
message: "Ошибка при скачивании файла",
description: "Не удалось загрузить файл.",
message: "Ошибка",
description: "Токен не найден",
placement: "topRight",
});
return;
}
const response = await fetch(`${CONFIG.BASE_URL}appointment_files/${fileId}/file/`, {
method: 'GET',
credentials: 'include',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
notification.error({
message: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || contentType.includes('text/html')) {
const errorText = await response.text();
notification.error({
message: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
let safeFileName = fileName || "file";
if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) {
if (contentType.includes('application/pdf')) {
safeFileName += '.pdf';
} else if (contentType.includes('image/jpeg')) {
safeFileName += '.jpg';
} else if (contentType.includes('image/png')) {
safeFileName += '.png';
} else {
safeFileName += '.bin';
}
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", fileName || "file");
link.setAttribute("download", safeFileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
} catch (error) {
console.error("Error downloading file:", error);
console.error("Error downloading file:", error); // Отладка
notification.error({
message: "Ошибка при скачивании файлов",
description: "Не удалось загрузить файл.",
message: "Ошибка",
description: error.message || "Не удалось скачать файл",
placement: "topRight",
});
} finally {
setDownloadingFiles((prev) => ({ ...prev, [fileId]: false }));
setDownloadingFiles((prev) => ({...prev, [fileId]: false}));
}
};
const deleteFile = async (fileId, fileName) => {
try {
setDeletingFiles((prev) => ({ ...prev, [fileId]: true }));
setDeletingFiles((prev) => ({...prev, [fileId]: true}));
await deleteAppointmentFile(fileId).unwrap();
notification.success({
message: "Файл удален",
@ -143,7 +173,7 @@ const useAppointmentView = () => {
placement: "topRight",
});
} finally {
setDeletingFiles((prev) => ({ ...prev, [fileId]: false }));
setDeletingFiles((prev) => ({...prev, [fileId]: false}));
}
};

View File

@ -11,7 +11,7 @@ const BackupManageTab = () => {
}
if (backupManageTabData.isErrorBackups) {
return <Result status={500} title="Произошла ошибка при загрузке резервных копий"/>
return <Result status={500} title="Произошла ошибка при загрузке резервных копий"/>;
}
return (
@ -22,7 +22,6 @@ const BackupManageTab = () => {
icon={<CloudDownloadOutlined/>}
onClick={backupManageTabData.createBackupHandler}
loading={backupManageTabData.isCreatingBackup}
>
Создать резервную копию
</Button>
@ -46,33 +45,35 @@ const BackupManageTab = () => {
renderItem={(backup) => (
<List.Item>
<Typography.Text>{backup.filename}</Typography.Text>
<Divider type={"vertical"}/>
<Typography.Text>{backup.is_by_user ? "Загружена" : "Создана"}: {new Date(backup.timestamp).toLocaleString()}</Typography.Text>
<Tooltip title="Скачать резервную копию">
<Button
type="primary"
icon={<CloudDownloadOutlined/>}
onClick={() => backupManageTabData.downloadBackupHandler(backup.id, backup.filename)}
loading={backupManageTabData.isDownloadingBackup}
>
Скачать
</Button>
</Tooltip>
<Tooltip title="Удалить резервную копию">
<Button
type="primary"
icon={<DeleteOutlined/>}
onClick={() => backupManageTabData.deleteBackupHandler(backup.id)}
loading={backupManageTabData.isDeletingBackup}
danger
>
Удалить
</Button>
</Tooltip>
<Space>
<Tooltip title="Скачать резервную копию">
<Button
type="primary"
icon={<CloudDownloadOutlined/>}
onClick={() => backupManageTabData.downloadBackupHandler(backup.id, backup.filename)}
loading={backupManageTabData.isDownloadingBackup}
>
Скачать
</Button>
</Tooltip>
<Tooltip title="Удалить резервную копию">
<Button
type="primary"
icon={<DeleteOutlined/>}
onClick={() => backupManageTabData.deleteBackupHandler(backup.id)}
loading={backupManageTabData.isDeletingBackup}
danger
>
Удалить
</Button>
</Tooltip>
</Space>
</List.Item>
)}
/>
</>
);
};

View File

@ -1,16 +1,15 @@
import {
useCreateBackupMutation,
useDeleteBackupMutation,
useGetBackupsQuery, useUploadBackupMutation
useGetBackupsQuery,
useUploadBackupMutation,
} from "../../../../../Api/backupsApi.js";
import {useDispatch} from "react-redux";
import {notification} from "antd";
import {baseQueryWithAuth} from "../../../../../Api/baseQuery.js";
import {useState} from "react";
import CONFIG from "../../../../../Core/сonfig.js";
const useBackupManageTab = () => {
const dispatch = useDispatch();
const {data: backups, isLoading: isLoadingBackups, isError: isErrorBackups} = useGetBackupsQuery(undefined, {
pollingInterval: 60000,
});
@ -40,47 +39,70 @@ const useBackupManageTab = () => {
const downloadBackupHandler = async (backupId, fileName) => {
try {
setDownloadingFiles(true);
const {url, ...options} = await baseQueryWithAuth(
{
url: `/backups/${backupId}/`,
method: 'GET',
credentials: 'include',
},
{},
{}
);
const response = await fetch(url, {
...options,
const token = localStorage.getItem('access_token');
if (!token) {
notification.error({
message: "Ошибка",
description: "Токен не найден",
placement: "topRight",
});
return;
}
const response = await fetch(`${CONFIG.BASE_URL}backups/${backupId}/file/`, {
method: 'GET',
credentials: 'include',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
notification.error({
message: "Ошибка при скачивании файла",
description: "Не удалось загрузить файл.",
message: "Ошибка",
description: errorText || "Не удалось скачать резервную копию",
placement: "topRight",
});
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || (!contentType.includes('application/gzip') && !contentType.includes('application/zip'))) {
const errorText = await response.text();
notification.error({
message: "Ошибка",
description: errorText || "Не удалось скачать резервную копию",
placement: "topRight",
});
return;
}
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.setAttribute("download", fileName || "file");
link.setAttribute("download", fileName || "backup.tar.gz");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
setDownloadingFiles(false);
} catch (e) {
console.log(e)
notification.error({
message: "Ошибка",
description: e?.data?.detail || "Не удалось загрузить резервную копию",
notification.success({
message: "Успех",
description: "Резервная копия успешно скачана",
placement: "topRight",
});
} catch (e) {
console.error("Download error:", e); // Отладка
notification.error({
message: "Ошибка",
description: e.message || "Не удалось скачать резервную копию",
placement: "topRight",
});
} finally {
setDownloadingFiles(false);
}
};
@ -118,6 +140,7 @@ const useBackupManageTab = () => {
placement: "topRight",
});
} catch (e) {
console.error("Upload error:", e); // Отладка
notification.error({
message: "Ошибка",
description: e.message || e?.data?.detail || "Не удалось загрузить резервную копию",
@ -138,7 +161,7 @@ const useBackupManageTab = () => {
downloadBackupHandler,
deleteBackupHandler,
uploadBackupHandler,
}
};
};
export default useBackupManageTab;

View File

@ -1,20 +1,23 @@
import { Badge, Button, FloatButton, List, Result, Row, Space, Tag, Typography } from "antd";
import { Splitter } from "antd";
import {Badge, Button, FloatButton, List, Result, Row, Space, Tag, Typography} from "antd";
import {Splitter} from "antd";
import {
CalendarOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PlusOutlined,
ClockCircleOutlined,
BellOutlined,
} from "@ant-design/icons";
import AppointmentsCalendar from "./Components/AppointmentCalendar/AppointmentsCalendar.jsx";
import useAppointments from "./useAppointments.js";
import useAppointmentsPage from "./useAppointmentsPage.js";
import dayjs from 'dayjs';
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import AppointmentFormModal from "../../Dummies/AppointmentFormModal/AppointmentFormModal.jsx";
import AppointmentViewModal from "../../Dummies/AppointmentViewModal/AppointmentViewModal.jsx";
import ScheduledAppointmentFormModal from "../../Dummies/ScheduledAppintmentFormModal/ScheduledAppointmentFormModal.jsx";
import ScheduledAppointmentsViewModal from "../../Widgets/ScheduledAppointmentsViewModal/ScheduledAppointmentsViewModal.jsx";
import ScheduledAppointmentFormModal
from "../../Dummies/ScheduledAppintmentFormModal/ScheduledAppointmentFormModal.jsx";
import ScheduledAppointmentsViewModal
from "../../Widgets/ScheduledAppointmentsViewModal/ScheduledAppointmentsViewModal.jsx";
import AppointmentsListModal from "./Components/AppointmentsListModal/AppointmentsListModal.jsx";
const AppointmentsPage = () => {
@ -44,7 +47,7 @@ const AppointmentsPage = () => {
handleMonthChange,
handleEventClick,
openCreateAppointmentModal,
} = useAppointments();
} = useAppointmentsPage();
if (isError) return (
<Result
@ -56,20 +59,24 @@ const AppointmentsPage = () => {
return (
<>
<Typography.Title level={1}><CalendarOutlined /> Приемы</Typography.Title>
<Typography.Title level={1}><CalendarOutlined/> Приемы</Typography.Title>
{isLoading ? (
<LoadingIndicator />
<LoadingIndicator/>
) : (
<>
<Row justify="end" style={{ marginBottom: 10, marginRight: "2.4rem" }}>
<Row justify="end" style={{marginBottom: 10, marginRight: "2.4rem"}}>
<Space direction={"vertical"}>
<Tag color={"blue"} style={{ width: "100%" }}>
<Tag color={"blue"} style={{width: "100%"}}>
<Badge status={"processing"}
text={<span style={badgeTextStyle}>Запланированный прием</span>} />
text={<span style={badgeTextStyle}>Запланированный прием</span>}/>
</Tag>
<Tag color={"green"} style={{ width: "100%" }}>
<Tag color={"green"} style={{width: "100%"}}>
<Badge status={"success"}
text={<span style={badgeTextStyle}>Прошедший прием</span>} />
text={<span style={badgeTextStyle}>Прошедший прием</span>}/>
</Tag>
<Tag color={"yellow"} style={{width: "100%"}}>
<Badge status={"warning"}
text={<span style={badgeTextStyle}>Напоминание о приеме</span>}/>
</Tag>
</Space>
</Row>
@ -89,8 +96,8 @@ const AppointmentsPage = () => {
<AppointmentsCalendar
currentMonth={currentMonth}
onMonthChange={handleMonthChange}
appointments={appointments} // Добавляем
scheduledAppointments={scheduledAppointments} // Добавляем
appointments={appointments}
scheduledAppointments={scheduledAppointments}
/>
</Splitter.Panel>
{showSplitterPanel && (
@ -105,10 +112,7 @@ const AppointmentsPage = () => {
</Typography.Title>
{upcomingEvents.length ? (
<List
dataSource={upcomingEvents.sort((a, b) =>
dayjs(a.appointment_datetime || a.scheduled_datetime).diff(
dayjs(b.appointment_datetime || b.scheduled_datetime)
))}
dataSource={upcomingEvents}
renderItem={(item) => (
<List.Item
onClick={() => handleEventClick(item)}
@ -116,32 +120,46 @@ const AppointmentsPage = () => {
padding: "12px",
marginBottom: "8px",
borderRadius: "4px",
background: item.appointment_datetime ? "#f6ffed" : "#e6f7ff",
background: item.type === "reminder" ? "#fff7e6" :
item.appointment_datetime ? "#f6ffed" : "#e6f7ff",
cursor: "pointer",
transition: "background 0.3s",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#efffdb" : "#d9efff")}
onMouseLeave={(e) => (e.currentTarget.style.background = item.appointment_datetime ? "#f6ffed" : "#e6f7ff")}
onMouseEnter={(e) => (e.currentTarget.style.background =
item.type === "reminder" ? "#ffecd2" :
item.appointment_datetime ? "#efffdb" : "#d9efff")}
onMouseLeave={(e) => (e.currentTarget.style.background =
item.type === "reminder" ? "#fff7e6" :
item.appointment_datetime ? "#f6ffed" : "#e6f7ff")}
>
<Space direction="vertical" size={2}>
<Space>
{item.appointment_datetime ? (
<ClockCircleOutlined style={{ color: "#52c41a" }} />
{item.type === "reminder" ? (
<BellOutlined style={{color: "#fa8c16"}}/>
) : item.appointment_datetime ? (
<ClockCircleOutlined style={{color: "#52c41a"}}/>
) : (
<CalendarOutlined style={{ color: "#1890ff" }} />
<CalendarOutlined style={{color: "#1890ff"}}/>
)}
<Typography.Text strong>
{dayjs(item.appointment_datetime || item.scheduled_datetime).format('DD.MM.YYYY HH:mm')}
{dayjs(item.type === "reminder" ? item.reminder_datetime :
(item.appointment_datetime || item.scheduled_datetime))
.format('DD.MM.YYYY HH:mm')}
</Typography.Text>
</Space>
<Typography.Text>
{item.appointment_datetime ? 'Прием' : 'Запланировано'}
{item.type === "reminder" ? "Напоминание" :
item.appointment_datetime ? "Прием" : "Запланировано"}
{item.patient ? ` - ${item.patient.last_name} ${item.patient.first_name}` : ''}
</Typography.Text>
<Typography.Text type="secondary">
Тип: {item.type?.title || 'Не указан'}
</Typography.Text>
{dayjs(item.appointment_datetime || item.scheduled_datetime).isSame(dayjs(), 'day') && (
{item.type !== "reminder" && (
<Typography.Text type="secondary">
Тип: {item.type === "reminder" ? item.type.title : item.type?.title || 'Не указан'}
</Typography.Text>
)}
{dayjs(item.type === "reminder" ? item.reminder_datetime :
(item.appointment_datetime || item.scheduled_datetime))
.isSame(dayjs(), 'day') && (
<Typography.Text type="warning">Сегодня</Typography.Text>
)}
</Space>
@ -162,7 +180,7 @@ const AppointmentsPage = () => {
<Button
type="primary"
onClick={handleToggleSider}
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
icon={collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>}
style={siderButtonStyle}
>
{siderButtonText}
@ -172,30 +190,30 @@ const AppointmentsPage = () => {
placement={"left"}
trigger="hover"
type="primary"
icon={<PlusOutlined />}
icon={<PlusOutlined/>}
tooltip="Создать"
>
<FloatButton
icon={<PlusOutlined />}
icon={<PlusOutlined/>}
onClick={openCreateAppointmentModal}
tooltip="Прием"
/>
<FloatButton
icon={<CalendarOutlined />}
icon={<CalendarOutlined/>}
onClick={openCreateScheduledAppointmentModal}
tooltip="Запланированный прием"
/>
</FloatButton.Group>
<AppointmentFormModal />
<AppointmentViewModal />
<ScheduledAppointmentFormModal />
<ScheduledAppointmentsViewModal />
<AppointmentsListModal />
<AppointmentFormModal/>
<AppointmentViewModal/>
<ScheduledAppointmentFormModal/>
<ScheduledAppointmentsViewModal/>
<AppointmentsListModal/>
</>
)}
</>
);
};
export default AppointmentsPage;
export default AppointmentsPage;

View File

@ -3,6 +3,7 @@ import {useDispatch, useSelector} from "react-redux";
import {notification} from "antd";
import {Grid} from "antd";
import {
useGetAppointmentRemindersQuery,
useGetAppointmentsQuery,
useGetUpcomingAppointmentsQuery,
} from "../../../Api/appointmentsApi.js";
@ -28,7 +29,7 @@ dayjs.extend(timezone);
const {useBreakpoint} = Grid;
const useAppointments = () => {
const useAppointmentsPage = () => {
const dispatch = useDispatch();
const {userData} = useSelector(state => state.auth);
const {collapsed, siderWidth, hovered, selectedAppointment} = useSelector(state => state.appointmentsUI);
@ -88,6 +89,15 @@ const useAppointments = () => {
skip: !userData.id,
});
const {
data: reminders = [],
isLoading: isLoadingReminders,
isError: isErrorReminders,
} = useGetAppointmentRemindersQuery(new Date().toISOString().split("T")[0], {
skip: !userData,
pollingInterval: 60000,
});
const [localSiderWidth, setLocalSiderWidth] = useState(siderWidth);
const splitterStyle = {flex: 1};
@ -133,7 +143,9 @@ const useAppointments = () => {
};
const handleEventClick = (event) => {
if (event.appointment_datetime) {
if (event.type === "reminder") {
dispatch(setSelectedAppointment(event));
} else if (event.appointment_datetime) {
dispatch(setSelectedAppointment(event));
} else {
dispatch(setSelectedScheduledAppointment(event));
@ -150,14 +162,34 @@ const useAppointments = () => {
hovered ? (collapsed ? "Показать предстоящие события" : "Скрыть предстоящие события") : "",
[collapsed, hovered]
);
const showSplitterPanel = useMemo(() => !collapsed && !screens.xs, [collapsed, screens]);
const upcomingEvents = useMemo(() =>
[...upcomingAppointments, ...upcomingScheduledAppointments]
.sort((a, b) => dayjs(a.appointment_datetime || a.scheduled_datetime) - dayjs(b.appointment_datetime || b.scheduled_datetime))
.slice(0, 5),
[upcomingAppointments, upcomingScheduledAppointments]
);
const upcomingEvents = useMemo(() => {
const remindersWithType = reminders.map(reminder => ({
...reminder,
type: "reminder",
reminder_datetime: dayjs(reminder.appointment_datetime).add(reminder.days_until_the_next_appointment, 'day').toISOString(),
}));
const appointmentsWithType = upcomingAppointments.map(appointment => ({
...appointment,
type: "appointment",
}));
const scheduledAppointmentsWithType = upcomingScheduledAppointments.map(scheduled => ({
...scheduled,
type: "scheduledAppointment",
}));
return [
...appointmentsWithType,
...scheduledAppointmentsWithType,
...remindersWithType
].sort((a, b) => {
const dateA = a.type === "reminder" ? a.reminder_datetime : (a.appointment_datetime || a.scheduled_datetime);
const dateB = b.type === "reminder" ? b.reminder_datetime : (b.appointment_datetime || b.scheduled_datetime);
return dayjs(dateA).diff(dayjs(dateB));
}).slice(0, 5);
}, [upcomingAppointments, upcomingScheduledAppointments, reminders]);
useEffect(() => {
document.title = "Приемы";
@ -199,18 +231,27 @@ const useAppointments = () => {
placement: 'topRight',
});
}
if (isErrorReminders) {
notification.error({
message: 'Ошибка',
description: 'Ошибка загрузки напоминаний.',
placement: 'topRight',
});
}
}, [
isErrorAppointments,
isErrorScheduledAppointments,
isErrorPatients,
isErrorUpcomingAppointments,
isErrorUpcomingScheduledAppointments
isErrorUpcomingScheduledAppointments,
isErrorReminders
]);
return {
patients,
appointments,
scheduledAppointments,
reminders,
isLoading: isLoadingAppointments || isLoadingScheduledAppointments || isLoadingPatients ||
isLoadingUpcomingAppointments || isLoadingUpcomingScheduledAppointments,
isError: isErrorAppointments || isErrorScheduledAppointments || isErrorPatients ||
@ -242,4 +283,4 @@ const useAppointments = () => {
};
};
export default useAppointments;
export default useAppointmentsPage;

View File

@ -1,18 +1,18 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { Grid, notification } from "antd";
import { setError, setUser } from "../../../Redux/Slices/authSlice.js";
import { useLoginMutation } from "../../../Api/authApi.js";
import { checkAuth } from "../../../Redux/Slices/authSlice.js";
import {useEffect, useRef} from "react";
import {useNavigate} from "react-router-dom";
import {useDispatch, useSelector} from "react-redux";
import {Grid, notification} from "antd";
import {setError, setUser} from "../../../Redux/Slices/authSlice.js";
import {useLoginMutation} from "../../../Api/authApi.js";
import {checkAuth} from "../../../Redux/Slices/authSlice.js";
const { useBreakpoint } = Grid;
const {useBreakpoint} = Grid;
const useLoginPage = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const [loginUser, { isLoading }] = useLoginMutation();
const { user, userData } = useSelector((state) => state.auth);
const [loginUser, {isLoading}] = useLoginMutation();
const {user, userData} = useSelector((state) => state.auth);
const screens = useBreakpoint();
const hasRedirected = useRef(false);
@ -74,10 +74,15 @@ const useLoginPage = () => {
const response = await loginUser(loginData).unwrap();
const token = response.access_token || response.token;
if (!token) {
throw new Error("Сервер не вернул токен авторизации");
notification.error({
message: "Ошибка при входе",
description: "Не удалось войти. Проверьте логин и пароль.",
placement: "topRight",
});
return;
}
localStorage.setItem("access_token", token);
dispatch(setUser({ token }));
dispatch(setUser({token}));
await dispatch(checkAuth()).unwrap();
} catch (error) {

View File

@ -1,11 +1,12 @@
import {Button, Card, Col, Row, Typography, Result} from "antd";
import {Button, Card, Col, Row, Typography, Result, List, Space} from "antd";
import {EditOutlined, UserOutlined} from "@ant-design/icons";
import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
import useProfilePage from "./useProfilePage.js";
import UpdateUserModalForm from "../../Dummies/UpdateUserModalForm/UpdateUserModalForm.jsx";
const ProfilePage = () => {
const {Title, Text} = Typography;
const ProfilePage = () => {
const {
containerStyle,
cardStyle,
@ -13,7 +14,12 @@ const ProfilePage = () => {
isLoading,
isError,
userData,
sessions,
handleEditUser,
handleLogoutSession,
handleLogoutAllSessions,
isLoggingOutSession,
isLoggingOutAll,
} = useProfilePage();
if (isError) {
@ -21,7 +27,7 @@ const ProfilePage = () => {
<Result
status="error"
title="Ошибка"
subTitle="Произошла ошибка при загрузке данных профиля"
subTitle="Произошла ошибка при загрузке данных профиля или сессий"
/>
);
}
@ -32,27 +38,31 @@ const ProfilePage = () => {
<LoadingIndicator/>
) : (
<>
<Typography.Title level={1}>
<Title level={1}>
<UserOutlined/> {userData.last_name} {userData.first_name}
</Typography.Title>
<Typography.Title level={1}>Профиль</Typography.Title>
<Card style={cardStyle}>
</Title>
<Title level={2}>Профиль</Title>
<Card style={cardStyle} title="Информация о пользователе">
<Row gutter={[16, 16]}>
<Col span={24}>
<Typography.Text strong>Фамилия: </Typography.Text>
<Typography.Text>{userData.last_name || "-"}</Typography.Text>
<Text strong>Фамилия: </Text>
<Text>{userData.last_name || "-"}</Text>
</Col>
<Col span={24}>
<Typography.Text strong>Имя: </Typography.Text>
<Typography.Text>{userData.first_name || "-"}</Typography.Text>
<Text strong>Имя: </Text>
<Text>{userData.first_name || "-"}</Text>
</Col>
<Col span={24}>
<Typography.Text strong>Отчество: </Typography.Text>
<Typography.Text>{userData.patronymic || "-"}</Typography.Text>
<Text strong>Отчество: </Text>
<Text>{userData.patronymic || "-"}</Text>
</Col>
<Col span={24}>
<Typography.Text strong>Логин: </Typography.Text>
<Typography.Text>{userData.login || "-"}</Typography.Text>
<Text strong>Логин: </Text>
<Text>{userData.login || "-"}</Text>
</Col>
<Col span={24}>
<Text strong>Роль: </Text>
<Text>{userData.role?.title || "-"}</Text>
</Col>
</Row>
<Button
@ -65,6 +75,44 @@ const ProfilePage = () => {
</Button>
</Card>
<Card style={cardStyle} title="Активные сессии">
<List
dataSource={sessions}
renderItem={(session) => (
<List.Item
actions={[
<Button
key={session.id}
type="primary"
danger
onClick={() => handleLogoutSession(session.id)}
loading={isLoggingOutSession}
disabled={isLoggingOutSession}
>
Завершить
</Button>,
]}
>
<List.Item.Meta
title={`Устройство: ${session.device_info || "Неизвестно"}`}
description={`Создана: ${new Date(session.created_at).toLocaleString()} | Истекает: ${new Date(session.expires_at).toLocaleString()}`}
/>
</List.Item>
)}
/>
<Space style={{marginTop: 16}}>
<Button
type="primary"
danger
onClick={handleLogoutAllSessions}
loading={isLoggingOutAll}
disabled={isLoggingOutAll}
>
Завершить все сессии
</Button>
</Space>
</Card>
<UpdateUserModalForm/>
</>
)}

View File

@ -1,40 +1,95 @@
import {Grid} from "antd";
import {useDispatch} from "react-redux";
import {useDispatch, useSelector} from "react-redux";
import {useNavigate} from "react-router-dom";
import {setSelectedUser} from "../../../Redux/Slices/usersSlice.js";
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
import {useGetSessionsQuery, useLogoutSessionMutation, useLogoutAllSessionsMutation} from "../../../Api/authApi.js";
import {logout, setError} from "../../../Redux/Slices/authSlice.js";
const {useBreakpoint} = Grid;
const useProfilePage = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const screens = useBreakpoint();
const {user} = useSelector((state) => state.auth);
const {
data: userData = {},
isLoading: isLoadingUserData,
isError: isErrorUserData,
} = useGetAuthenticatedUserDataQuery(undefined, {
pollingInterval: 10000,
});
const {
data: sessions = [],
isLoading: isLoadingSessions,
isError: isErrorSessions,
error: sessionsError,
} = useGetSessionsQuery(undefined, {
skip: !user,
pollingInterval: 20000,
});
const [logoutSession, {isLoading: isLoggingOutSession}] = useLogoutSessionMutation();
const [logoutAllSessions, {isLoading: isLoggingOutAll}] = useLogoutAllSessionsMutation();
const containerStyle = {padding: screens.xs ? 16 : 24};
const cardStyle = {marginBottom: 24};
const buttonStyle = {width: screens.xs ? "100%" : "auto"};
const handleEditUser = () => {
dispatch(setSelectedUser(userData))
dispatch(setSelectedUser(userData));
};
const handleLogoutSession = async (sessionId) => {
try {
await logoutSession(sessionId).unwrap();
} catch (error) {
const errorMessage = error?.data?.detail || "Не удалось завершить сессию";
dispatch(setError(errorMessage));
if (error?.status === 401) {
dispatch(logout());
navigate("/login");
}
}
};
const handleLogoutAllSessions = async () => {
try {
await logoutAllSessions().unwrap();
dispatch(logout());
navigate("/login");
} catch (error) {
const errorMessage = error?.data?.detail || "Не удалось завершить все сессии";
dispatch(setError(errorMessage));
if (error?.status === 401) {
dispatch(logout());
navigate("/login");
}
}
};
if (isErrorSessions && sessionsError?.status === 401) {
dispatch(logout());
navigate("/login");
}
return {
userData,
sessions,
containerStyle,
cardStyle,
buttonStyle,
isMobile: screens.xs,
isLoading: isLoadingUserData,
isError: isErrorUserData,
isLoading: isLoadingUserData || isLoadingSessions,
isError: isErrorUserData || isErrorSessions,
handleEditUser,
handleLogoutSession,
handleLogoutAllSessions,
isLoggingOutSession,
isLoggingOutAll,
};
};

View File

@ -1,7 +1,7 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { usersApi } from "../../Api/usersApi.js";
import {createSlice, createAsyncThunk} from "@reduxjs/toolkit";
import {usersApi} from "../../Api/usersApi.js";
export const checkAuth = createAsyncThunk("auth/checkAuth", async (_, { dispatch, rejectWithValue }) => {
export const checkAuth = createAsyncThunk("auth/checkAuth", async (_, {dispatch, rejectWithValue}) => {
try {
const token = localStorage.getItem("access_token");
if (!token) {
@ -9,10 +9,10 @@ export const checkAuth = createAsyncThunk("auth/checkAuth", async (_, { dispatch
}
const userData = await dispatch(
usersApi.endpoints.getAuthenticatedUserData.initiate(undefined, { forceRefetch: true })
usersApi.endpoints.getAuthenticatedUserData.initiate(undefined, {forceRefetch: true})
).unwrap();
return { token, userData };
return {token, userData};
} catch (error) {
localStorage.removeItem("access_token");
return rejectWithValue(error?.data?.detail || "Failed to authenticate");
@ -38,6 +38,9 @@ const authSlice = createSlice({
setError(state, action) {
state.error = action.payload;
state.isLoading = false;
state.user = null;
state.userData = null;
localStorage.removeItem("access_token");
},
logout(state) {
state.user = null;
@ -57,7 +60,7 @@ const authSlice = createSlice({
state.error = null;
})
.addCase(checkAuth.fulfilled, (state, action) => {
state.user = { token: action.payload.token };
state.user = {token: action.payload.token};
state.userData = action.payload.userData;
state.isLoading = false;
})
@ -70,5 +73,5 @@ const authSlice = createSlice({
},
});
export const { setUser, setError, logout, setUserData } = authSlice.actions;
export const {setUser, setError, logout, setUserData} = authSlice.actions;
export default authSlice.reducer;