Compare commits
6 Commits
b52c0d16fa
...
a5fccd0710
| Author | SHA1 | Date | |
|---|---|---|---|
| a5fccd0710 | |||
| 59b77a665b | |||
| dc47e4b003 | |||
| 89bfb07a23 | |||
| 8042460557 | |||
| 04b9682f1e |
@ -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)
|
||||
|
||||
55
api/app/application/sessions_repository.py
Normal file
55
api/app/application/sessions_repository.py
Normal 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()
|
||||
@ -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],
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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',
|
||||
)
|
||||
|
||||
@ -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 ###
|
||||
16
api/app/domain/entities/responses/session.py
Normal file
16
api/app/domain/entities/responses/session.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
20
api/app/domain/models/sessions.py
Normal file
20
api/app/domain/models/sessions.py
Normal 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")
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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="Пользователь не найден")
|
||||
|
||||
@ -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'])
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -40,7 +40,6 @@ export const backupsApi = createApi({
|
||||
},
|
||||
invalidatesTags: ['Backup'],
|
||||
}),
|
||||
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ const AdminRoute = () => {
|
||||
}
|
||||
|
||||
if (isUserError) {
|
||||
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке данных пользователя"/>;
|
||||
return <Navigate to="/login"/>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
||||
@ -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"/>;
|
||||
}
|
||||
|
||||
|
||||
@ -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}));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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) {
|
||||
|
||||
@ -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/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user