diff --git a/src/elexam_core/errors.py b/src/elexam_core/errors.py new file mode 100644 index 0000000..f81e2ef --- /dev/null +++ b/src/elexam_core/errors.py @@ -0,0 +1,281 @@ +from enum import Enum + + +class ErrorCode(str, Enum): + + # --- common --- + # Универсальные коды для случаев, когда доменный код избыточен + COMMON_INTERNAL_ERROR = "common.internal_error" + COMMON_SERVICE_UNAVAILABLE = "common.service_unavailable" + COMMON_VALIDATION_FAILED = "common.validation_failed" + COMMON_NOT_FOUND = "common.not_found" + COMMON_FORBIDDEN = "common.forbidden" + COMMON_UNAUTHORIZED = "common.unauthorized" + COMMON_RATE_LIMITED = "common.rate_limited" + COMMON_CONFLICT = "common.conflict" + COMMON_METHOD_NOT_ALLOWED = "common.method_not_allowed" + COMMON_PAYLOAD_TOO_LARGE = "common.payload_too_large" + + # --- auth --- + AUTH_TOKEN_EXPIRED = "auth.token.expired" + AUTH_TOKEN_INVALID = "auth.token.invalid" + AUTH_TOKEN_REVOKED = "auth.token.revoked" + AUTH_CREDENTIALS_INVALID = "auth.credentials.invalid" + AUTH_ACCOUNT_LOCKED = "auth.account.locked" + AUTH_ACCOUNT_DEPRECATED = "auth.account.deprecated" + AUTH_DEVICE_UNTRUSTED = "auth.device.untrusted" + AUTH_DEVICE_NOT_FOUND = "auth.device.not_found" + AUTH_REFRESH_EXPIRED = "auth.refresh.expired" + AUTH_REFRESH_REVOKED = "auth.refresh.revoked" + # Применяется, когда организация заморожена gateway'ем по событию billing + AUTH_ORG_SUSPENDED = "auth.org.suspended" + # Порог брутфорса достигнут — фиксируется журналом + rate-limit-ом + AUTH_BRUTE_FORCE = "auth.brute_force" + + # --- user --- + USER_NOT_FOUND = "user.not_found" + USER_EMAIL_TAKEN = "user.email.taken" + USER_DEPRECATED = "user.deprecated" + USER_ORG_NOT_FOUND = "user.org.not_found" + USER_ORG_DEPRECATED = "user.org.deprecated" + USER_BRANCH_NOT_FOUND = "user.branch.not_found" + USER_MEMBERSHIP_EXISTS = "user.membership.exists" + USER_MEMBERSHIP_NOT_FOUND = "user.membership.not_found" + USER_ROLE_NOT_FOUND = "user.role.not_found" + USER_PERMISSION_NOT_FOUND = "user.permission.not_found" + + # --- exam --- + EXAM_NOT_FOUND = "exam.not_found" + EXAM_ALREADY_PUBLISHED = "exam.already_published" + EXAM_CLOSED = "exam.closed" + # Нельзя опубликовать черновик без единого вопроса + EXAM_DRAFT_EMPTY = "exam.draft.empty" + EXAM_VERSION_NOT_FOUND = "exam.version.not_found" + EXAM_QUESTION_NOT_FOUND = "exam.question.not_found" + EXAM_QUESTION_DUPLICATE = "exam.question.duplicate" + EXAM_ATTEMPT_NOT_FOUND = "exam.attempt.not_found" + # Попытка уже запущена — двойной старт невозможен + EXAM_ATTEMPT_ALREADY_STARTED = "exam.attempt.already_started" + # Студент исчерпал максимум попыток (exam.settings.attempts.max) + EXAM_ATTEMPT_MAX_EXCEEDED = "exam.attempt.max_exceeded" + # Серверный дедлайн истёк до сабмита + EXAM_ATTEMPT_TIMED_OUT = "exam.attempt.timed_out" + # Попытка аннулирована по вердикту прокторинга + EXAM_ATTEMPT_VOIDED = "exam.attempt.voided" + # Попытка уже закрыта (submitted) — нельзя добавлять ответы + EXAM_ATTEMPT_SUBMITTED = "exam.attempt.submitted" + # Старт заблокирован: нет факта согласия (proctoring.consent_given) + EXAM_ATTEMPT_CONSENT_REQUIRED = "exam.attempt.consent_required" + # Повторная попытка раньше cooldown_seconds + EXAM_ATTEMPT_COOLDOWN_ACTIVE = "exam.attempt.cooldown_active" + + # --- proctoring --- + PROCTORING_CONSENT_ALREADY_GIVEN = "proctoring.consent.already_given" + PROCTORING_CONSENT_NOT_FOUND = "proctoring.consent.not_found" + PROCTORING_SESSION_NOT_FOUND = "proctoring.session.not_found" + PROCTORING_SESSION_ALREADY_ACTIVE = "proctoring.session.already_active" + # Сессия уже закрыта (closed) — новые сигналы не принимаются + PROCTORING_SESSION_CLOSED = "proctoring.session.closed" + # Сигнал не прошёл валидацию схемы (неизвестный type, невалидный payload) + PROCTORING_SIGNAL_REJECTED = "proctoring.signal.rejected" + PROCTORING_INCIDENT_NOT_FOUND = "proctoring.incident.not_found" + # Вердикт по этой сессии уже вынесен (один вердикт на сессию — аудит) + PROCTORING_VERDICT_ALREADY_ISSUED = "proctoring.verdict.already_issued" + PROCTORING_VERDICT_NOT_FOUND = "proctoring.verdict.not_found" + # Снимок для ручной идентификации не загружен при открытии сессии + PROCTORING_IDENTITY_SHOT_MISSING = "proctoring.identity_shot.missing" + # Клиент пытается слать данные по каналу (напр. mic), на который не давал согласия + PROCTORING_CHANNEL_NOT_CONSENTED = "proctoring.channel.not_consented" + + # --- result --- + RESULT_NOT_FOUND = "result.not_found" + # Результат аннулирован — изменения невозможны + RESULT_VOIDED = "result.voided" + # Итог уже финализирован — повторная финализация запрещена + RESULT_ALREADY_FINALIZED = "result.already_finalized" + # Финализация невозможна: остались непроверенные grading-записи + RESULT_PENDING_GRADING = "result.pending_grading" + RESULT_GRADING_NOT_FOUND = "result.grading.not_found" + # Вопрос уже оценён — повторная оценка без апелляции запрещена + RESULT_GRADING_ALREADY_DONE = "result.grading.already_done" + # Апелляции отключены в настройках экзамена (appeals.enabled = false) + RESULT_APPEAL_NOT_ALLOWED = "result.appeal.not_allowed" + RESULT_APPEAL_NOT_FOUND = "result.appeal.not_found" + # По этому результату уже открыта апелляция (одна активная) + RESULT_APPEAL_ALREADY_OPEN = "result.appeal.already_open" + # Апелляция уже закрыта (accepted/rejected) + RESULT_APPEAL_CLOSED = "result.appeal.closed" + + # --- notification --- + NOTIFICATION_NOT_FOUND = "notification.not_found" + NOTIFICATION_TEMPLATE_NOT_FOUND = "notification.template.not_found" + # Канал (email/telegram) отключён настройками пользователя + NOTIFICATION_CHANNEL_DISABLED = "notification.channel.disabled" + # Внешний провайдер (SMTP / Telegram API) вернул ошибку + NOTIFICATION_DELIVERY_FAILED = "notification.delivery.failed" + # Уведомление по этому event_id уже существует (идемпотентный дедуп) + NOTIFICATION_DUPLICATE = "notification.duplicate" + NOTIFICATION_PREF_NOT_FOUND = "notification.pref.not_found" + + # --- billing --- + BILLING_PLAN_NOT_FOUND = "billing.plan.not_found" + BILLING_SUBSCRIPTION_NOT_FOUND = "billing.subscription.not_found" + BILLING_SUBSCRIPTION_ALREADY_EXISTS = "billing.subscription.already_exists" + # Подписка истекла и не продлена (период закончился) + BILLING_SUBSCRIPTION_EXPIRED = "billing.subscription.expired" + # Организация заморожена (suspended) — операции недоступны + BILLING_SUBSCRIPTION_SUSPENDED = "billing.subscription.suspended" + # Лимит тарифа исчерпан (напр. max_concurrent_attempts) + BILLING_LIMIT_EXCEEDED = "billing.limit.exceeded" + BILLING_INVOICE_NOT_FOUND = "billing.invoice.not_found" + # Платёжный провайдер отклонил транзакцию + BILLING_PAYMENT_FAILED = "billing.payment.failed" + + # --- bot --- + BOT_LINK_NOT_FOUND = "bot.link.not_found" + # Пользователь уже привязан к Telegram-чату + BOT_LINK_ALREADY_EXISTS = "bot.link.already_exists" + BOT_LINK_CODE_NOT_FOUND = "bot.link_code.not_found" + # Одноразовый код привязки истёк (expires_at пройден) + BOT_LINK_CODE_EXPIRED = "bot.link_code.expired" + # Код уже был активирован — повторное использование невозможно + BOT_LINK_CODE_USED = "bot.link_code.used" + # Telegram Bot API вернул ошибку при отправке + BOT_DELIVERY_FAILED = "bot.delivery.failed" + + # --- storage --- + STORAGE_FILE_NOT_FOUND = "storage.file.not_found" + STORAGE_UPLOAD_TOO_LARGE = "storage.upload.too_large" + STORAGE_TYPE_NOT_ALLOWED = "storage.type.not_allowed" + # Квота хранилища организации исчерпана (billing.limits.storage_gb) + STORAGE_QUOTA_EXCEEDED = "storage.quota.exceeded" + # Хранилище недоступно или вернуло ошибку при записи + STORAGE_UPLOAD_FAILED = "storage.upload.failed" + + # --- journal --- + JOURNAL_AUDIT_NOT_FOUND = "journal.audit.not_found" + JOURNAL_STATS_NOT_FOUND = "journal.stats.not_found" + # Запрошенный экспорт слишком велик для синхронной выдачи + JOURNAL_EXPORT_TOO_LARGE = "journal.export.too_large" + + +ERROR_META: dict[ErrorCode, dict] = { + + # --- common --- + ErrorCode.COMMON_INTERNAL_ERROR: {"http": 500, "severity": "error"}, + ErrorCode.COMMON_SERVICE_UNAVAILABLE: {"http": 503, "severity": "error"}, + ErrorCode.COMMON_VALIDATION_FAILED: {"http": 422, "severity": "warning"}, + ErrorCode.COMMON_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.COMMON_FORBIDDEN: {"http": 403, "severity": "warning"}, + ErrorCode.COMMON_UNAUTHORIZED: {"http": 401, "severity": "warning"}, + ErrorCode.COMMON_RATE_LIMITED: {"http": 429, "severity": "warning"}, + ErrorCode.COMMON_CONFLICT: {"http": 409, "severity": "warning"}, + ErrorCode.COMMON_METHOD_NOT_ALLOWED: {"http": 405, "severity": "warning"}, + ErrorCode.COMMON_PAYLOAD_TOO_LARGE: {"http": 413, "severity": "warning"}, + + # --- auth --- + ErrorCode.AUTH_TOKEN_EXPIRED: {"http": 401, "severity": "info"}, + ErrorCode.AUTH_TOKEN_INVALID: {"http": 401, "severity": "warning"}, + ErrorCode.AUTH_TOKEN_REVOKED: {"http": 401, "severity": "info"}, + ErrorCode.AUTH_CREDENTIALS_INVALID: {"http": 401, "severity": "warning"}, + ErrorCode.AUTH_ACCOUNT_LOCKED: {"http": 403, "severity": "warning"}, + ErrorCode.AUTH_ACCOUNT_DEPRECATED: {"http": 403, "severity": "info"}, + ErrorCode.AUTH_DEVICE_UNTRUSTED: {"http": 403, "severity": "info"}, + ErrorCode.AUTH_DEVICE_NOT_FOUND: {"http": 404, "severity": "info"}, + ErrorCode.AUTH_REFRESH_EXPIRED: {"http": 401, "severity": "info"}, + ErrorCode.AUTH_REFRESH_REVOKED: {"http": 401, "severity": "info"}, + ErrorCode.AUTH_ORG_SUSPENDED: {"http": 403, "severity": "warning"}, + ErrorCode.AUTH_BRUTE_FORCE: {"http": 429, "severity": "error"}, + + # --- user --- + ErrorCode.USER_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.USER_EMAIL_TAKEN: {"http": 409, "severity": "info"}, + ErrorCode.USER_DEPRECATED: {"http": 403, "severity": "info"}, + ErrorCode.USER_ORG_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.USER_ORG_DEPRECATED: {"http": 403, "severity": "warning"}, + ErrorCode.USER_BRANCH_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.USER_MEMBERSHIP_EXISTS: {"http": 409, "severity": "info"}, + ErrorCode.USER_MEMBERSHIP_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.USER_ROLE_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.USER_PERMISSION_NOT_FOUND: {"http": 404, "severity": "warning"}, + + # --- exam --- + ErrorCode.EXAM_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.EXAM_ALREADY_PUBLISHED: {"http": 409, "severity": "info"}, + ErrorCode.EXAM_CLOSED: {"http": 409, "severity": "warning"}, + ErrorCode.EXAM_DRAFT_EMPTY: {"http": 422, "severity": "warning"}, + ErrorCode.EXAM_VERSION_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.EXAM_QUESTION_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.EXAM_QUESTION_DUPLICATE: {"http": 409, "severity": "info"}, + ErrorCode.EXAM_ATTEMPT_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.EXAM_ATTEMPT_ALREADY_STARTED: {"http": 409, "severity": "info"}, + ErrorCode.EXAM_ATTEMPT_MAX_EXCEEDED: {"http": 409, "severity": "warning"}, + ErrorCode.EXAM_ATTEMPT_TIMED_OUT: {"http": 409, "severity": "warning"}, + ErrorCode.EXAM_ATTEMPT_VOIDED: {"http": 409, "severity": "warning"}, + ErrorCode.EXAM_ATTEMPT_SUBMITTED: {"http": 409, "severity": "info"}, + ErrorCode.EXAM_ATTEMPT_CONSENT_REQUIRED: {"http": 403, "severity": "warning"}, + ErrorCode.EXAM_ATTEMPT_COOLDOWN_ACTIVE: {"http": 429, "severity": "info"}, + + # --- proctoring --- + ErrorCode.PROCTORING_CONSENT_ALREADY_GIVEN: {"http": 409, "severity": "info"}, + ErrorCode.PROCTORING_CONSENT_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.PROCTORING_SESSION_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.PROCTORING_SESSION_ALREADY_ACTIVE: {"http": 409, "severity": "info"}, + ErrorCode.PROCTORING_SESSION_CLOSED: {"http": 409, "severity": "warning"}, + ErrorCode.PROCTORING_SIGNAL_REJECTED: {"http": 422, "severity": "info"}, + ErrorCode.PROCTORING_INCIDENT_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.PROCTORING_VERDICT_ALREADY_ISSUED: {"http": 409, "severity": "info"}, + ErrorCode.PROCTORING_VERDICT_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.PROCTORING_IDENTITY_SHOT_MISSING: {"http": 422, "severity": "warning"}, + ErrorCode.PROCTORING_CHANNEL_NOT_CONSENTED: {"http": 403, "severity": "info"}, + + # --- result --- + ErrorCode.RESULT_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.RESULT_VOIDED: {"http": 409, "severity": "warning"}, + ErrorCode.RESULT_ALREADY_FINALIZED: {"http": 409, "severity": "info"}, + ErrorCode.RESULT_PENDING_GRADING: {"http": 409, "severity": "info"}, + ErrorCode.RESULT_GRADING_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.RESULT_GRADING_ALREADY_DONE: {"http": 409, "severity": "info"}, + ErrorCode.RESULT_APPEAL_NOT_ALLOWED: {"http": 403, "severity": "info"}, + ErrorCode.RESULT_APPEAL_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.RESULT_APPEAL_ALREADY_OPEN: {"http": 409, "severity": "info"}, + ErrorCode.RESULT_APPEAL_CLOSED: {"http": 409, "severity": "info"}, + + # --- notification --- + ErrorCode.NOTIFICATION_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.NOTIFICATION_TEMPLATE_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.NOTIFICATION_CHANNEL_DISABLED: {"http": 422, "severity": "info"}, + ErrorCode.NOTIFICATION_DELIVERY_FAILED: {"http": 502, "severity": "error"}, + ErrorCode.NOTIFICATION_DUPLICATE: {"http": 409, "severity": "info"}, + ErrorCode.NOTIFICATION_PREF_NOT_FOUND: {"http": 404, "severity": "info"}, + + # --- billing --- + ErrorCode.BILLING_PLAN_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.BILLING_SUBSCRIPTION_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.BILLING_SUBSCRIPTION_ALREADY_EXISTS: {"http": 409, "severity": "info"}, + ErrorCode.BILLING_SUBSCRIPTION_EXPIRED: {"http": 403, "severity": "warning"}, + ErrorCode.BILLING_SUBSCRIPTION_SUSPENDED: {"http": 403, "severity": "warning"}, + ErrorCode.BILLING_LIMIT_EXCEEDED: {"http": 429, "severity": "warning"}, + ErrorCode.BILLING_INVOICE_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.BILLING_PAYMENT_FAILED: {"http": 402, "severity": "error"}, + + # --- bot --- + ErrorCode.BOT_LINK_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.BOT_LINK_ALREADY_EXISTS: {"http": 409, "severity": "info"}, + ErrorCode.BOT_LINK_CODE_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.BOT_LINK_CODE_EXPIRED: {"http": 410, "severity": "info"}, + ErrorCode.BOT_LINK_CODE_USED: {"http": 409, "severity": "info"}, + ErrorCode.BOT_DELIVERY_FAILED: {"http": 502, "severity": "error"}, + + # --- storage --- + ErrorCode.STORAGE_FILE_NOT_FOUND: {"http": 404, "severity": "warning"}, + ErrorCode.STORAGE_UPLOAD_TOO_LARGE: {"http": 413, "severity": "warning"}, + ErrorCode.STORAGE_TYPE_NOT_ALLOWED: {"http": 422, "severity": "warning"}, + ErrorCode.STORAGE_QUOTA_EXCEEDED: {"http": 429, "severity": "warning"}, + ErrorCode.STORAGE_UPLOAD_FAILED: {"http": 502, "severity": "error"}, + + # --- journal --- + ErrorCode.JOURNAL_AUDIT_NOT_FOUND: {"http": 404, "severity": "info"}, + ErrorCode.JOURNAL_STATS_NOT_FOUND: {"http": 404, "severity": "info"}, + ErrorCode.JOURNAL_EXPORT_TOO_LARGE: {"http": 413, "severity": "warning"}, +}