diff --git a/.env.sample b/.env.sample index ab2b58eeb..ae9bd4bc2 100644 --- a/.env.sample +++ b/.env.sample @@ -46,4 +46,8 @@ DISCORD_GUILD_ID=xxx DISCORD_BOT_TOKEN=xxx QSEVERSE_BASE_URL= -QSEVERSE_API_KEY= \ No newline at end of file +QSEVERSE_API_KEY= + +BACKEND_API_KEY= + +ENABLE_SWAGGER= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 59ebce848..7f2299c34 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,6 @@ env/ venv/ .vscode .idea -dump.rdb \ No newline at end of file +dump.rdb + +static/ \ No newline at end of file diff --git a/.greptile/rules.md b/.greptile/rules.md new file mode 100644 index 000000000..1b2e6c12f --- /dev/null +++ b/.greptile/rules.md @@ -0,0 +1,323 @@ +# muLearn Backend — PR Review Rules + +These rules exist to protect the architecture, security model, and data integrity of this +specific Django REST API. They are not generic Django advice. Enforce them when they apply, +explain the concrete failure when you flag something, and stay quiet otherwise. Prefer a few +accurate comments over many speculative ones. + +--- + +## 1. Repository Overview + +muLearn is the backend for a community learning platform. It exposes a JSON API consumed by +multiple frontends (web dashboard, mobile app, Discord bot, and partner integrations such as +KKEM, Wadhwani, and QSEverse). + +Top-level layout: + +- **`db/`** — every model in the system, split into per-domain files (`user.py`, `task.py`, + `organization.py`, `events.py`, `hackathon.py`, `intern.py`, `mentor.py`, `projects.py`, + `learning_circle.py`, `launchpad.py`, `donation.py`, etc.). This app owns the data layer only. +- **`api/`** — all views, grouped by feature area. `api/dashboard//` holds the bulk of + the admin/dashboard surface; each domain folder contains `*_views.py`, `*_serializer.py`, and + `urls.py`. Other top-level API areas: `auth/`, `register/`, `integrations/`, `hackathon/`, + `leaderboard/`, `notification/`, `protected/`, `launchpad/`, `donate/`, `calendar/`, + `top100_coders/`, `url_shortener/`, `common/`. +- **`utils/`** — cross-cutting building blocks every view depends on: `response.py` + (`CustomResponse`), `permission.py` (JWT + role decorators), `exception.py`, `karma.py` + (karma/wallet business logic), `utils.py` (`CommonUtils`, `DateTimeUtils`, `DiscordWebhooks`, + mail helpers). +- **`mu_celery/`** — Celery tasks and scheduled crons (intern status, deadlines, achievements). +- **`mulearnbackend/`** — project config: `settings.py`, `urls.py`, `middlewares.py`, + `celery.py`, ASGI/WSGI/routing entrypoints. + +Routing: `mulearnbackend/urls.py` mounts everything under `/api/v1/` via `api/urls.py`, which +fans out to each area's `urls.py`. + +--- + +## 2. Detected Stack + +- **Python / Django 4.2.7**, **DRF 3.14.0**. +- **Database: MySQL** (`mysqlclient`, `pymysql`). `CONN_MAX_AGE = 600`. +- **All models are `managed = False`.** The database schema is owned externally (see + `schema.sql` and `alter-scripts/`). Django migrations are **not** the source of truth. +- **Auth: custom JWT (HS256, signed with `SECRET_KEY`)**, validated manually in + `utils/permission.py`. `djangorestframework-simplejwt` is installed but the live path is the + custom `CustomizePermission` / `JWTUtils`. Token *issuance* happens on a separate auth service + (`AUTH_DOMAIN`); this backend primarily **validates** tokens. +- **Background: Celery 5.4 + Redis broker** (`redis://.../2`). Beat schedule defined in settings. +- **Cache: Redis** (`django_redis`) is the default cache; channels use `channels-redis`. +- **Async: Daphne / ASGI** (`channels`) for websocket routing. +- **API docs: drf-spectacular 0.27.2** — schema at `/api/schema/`, Swagger gated behind + `ENABLE_SWAGGER`. +- **External services:** Razorpay (payments), Wadhwani, KKEM, QSEverse, Discord webhooks, SMTP email. +- **Config: `python-decouple`** (`decouple_config` / `.env`). Secrets never live in code. +- **Tests: pytest + pytest-django** with `APIClient` and `conftest.py` fixtures (currently only + `projects/`, `mentor/`, `events/` are covered). + +Rules must match this stack. Do not suggest tooling or patterns that aren't already here +(e.g. DRF ViewSets/routers, Alembic, dataclass DTOs, async views) unless fixing a proven bug. + +--- + +## 3. Architecture Rules + +- **Respect the `db/` ↔ `api/` ↔ `utils/` split.** Models live in `db/.py`, request + handling in `api//`, shared helpers in `utils/`. Do not define models inside `api/`, and + do not put HTTP/response logic in `db/`. +- **New endpoints follow the existing folder shape**: a `*_views.py`, a `*_serializer.py`, and a + `urls.py` wired into the parent `urls.py`. Don't introduce a parallel structure. +- **This codebase deliberately does not use a service/repository layer.** Business logic lives in + views and in focused `utils/` helpers (e.g. `utils/karma.py`). Do not recommend adding service + classes, repositories, CQRS, or new abstraction layers. Reuse the existing helper instead. +- **Shared domain logic must be reused, not re-implemented.** Karma/wallet mutations go through + `utils/karma.py` (`add_karma` / `remove_karma`). Pagination/search/sort go through + `CommonUtils.get_paginated_queryset`. CSV export goes through `CommonUtils.generate_csv`. Email + goes through `send_template_mail`. Flag any inline reimplementation of these. +- **Cross-module impact:** a `db/` model change is never local. Before approving, trace which + serializers expose the field, which views/filters reference it, which Celery tasks read it, and + which integration consumes it. Call out the affected API contracts explicitly. + +--- + +## 4. Model Rules (`db/`) + +- **All models are `managed = False` with an explicit `db_table`.** Any new model or field must + exactly mirror the real database (`db_column`, `db_table`, types, nullability). A model change + here does **not** alter the database — the corresponding schema change must land separately in + `schema.sql` / `alter-scripts/`. Flag any model/field addition that has no matching schema + artifact, and never assume `makemigrations`/`migrate` will apply it. +- **Primary keys are `CharField(max_length=36)` UUIDs** (`default=uuid.uuid4`). New tables follow + this; do not introduce `AutoField`/integer PKs or change PK type on existing tables. +- **Audit columns follow a fixed pattern:** `created_by` / `updated_by` are FKs to `User` with + `on_delete=models.SET(settings.SYSTEM_ADMIN_ID)` and an explicit `db_column`; `created_at` is + `auto_now_add`, `updated_at` is `auto_now`. New tables that record actors must keep this + pattern so the `SYSTEM_ADMIN_ID` fallback protects against user deletion. Don't use + `on_delete=CASCADE` for `created_by`/`updated_by`. +- **`on_delete` choices encode real ownership.** `CASCADE` only where the child truly cannot + outlive the parent; `SET_NULL` / `SET(SYSTEM_ADMIN_ID)` for audit/actor references. Question any + change that flips this, because deletes propagate across a heavily interlinked schema. +- **Do not suggest adding indexes or constraints reflexively.** Indexes belong in the externally + managed schema, not in `Meta` on an unmanaged model. Only raise an index if there is a concrete + filter/order on a large table *and* note it must be added to the DB schema, not via Django. +- **Keep `choices` in sync with `utils/types.py` enums** (`RoleType`, `OrganizationType`, + status enums, etc.). Magic strings that duplicate an existing enum value should reference the enum. + +--- + +## 5. ORM / Performance Rules + +- **`User.objects` excludes suspended users.** `ActiveUserManager` filters out + `suspended_at`/`suspended_by`. Use `User.objects` for normal user-facing reads; use + **`User.every`** when you must include suspended users (admin/management views, suspension + workflows, FK integrity checks, lookups by id where the row may be suspended). Flag code that + uses `User.objects` and then is surprised a known user "doesn't exist" — that is almost always a + suspended-user bug. +- **List endpoints must `select_related` / `prefetch_related` everything the serializer touches.** + This API serializes deep relations (e.g. `org.district.zone.state.country`, wallet/level links, + role links). A list view that serializes a related field without prefetching it produces one + query per row. State the exact field and the per-row cost when flagging. +- **Watch for N+1 hidden inside `SerializerMethodField`.** Several serializers run queries + per-object inside method fields (e.g. resolving roles, dynamic types, company org). When a new + method field issues a query, require it to be backed by a prefetch or annotation on the queryset. +- **Use `.exists()` / `.count()` / `values_list` instead of materializing full querysets** for + presence and id checks — this is the established style (see `utils/karma.py`, the intern crons). +- **All list endpoints are paginated through `CommonUtils.get_paginated_queryset`** and returned + via `CustomResponse(...).paginated_response(...)`. A new list endpoint that returns an unbounded + queryset is a regression. + +--- + +## 6. API / View Rules + +- **Views are `APIView` subclasses with one method per HTTP verb.** Do not introduce DRF + `ViewSet`s, routers, or generic views — they don't fit the response envelope or the per-method + decorator pattern used everywhere here. +- **Every view returns a `CustomResponse`.** Use `get_success_response()`, + `get_failure_response()`, `get_unauthorized_response()`, or `paginated_response()`. Never return + a raw DRF `Response`, `JsonResponse`, or `HttpResponse` from an API view (the image endpoints via + `ImageResponse` and CSV downloads are the only sanctioned exceptions). Breaking the + `{hasError, statusCode, message, response}` envelope breaks every client. +- **Validation failures return `get_failure_response(...)` with a message**; they do not raise. + Uncaught exceptions are logged and re-raised by `UniversalErrorHandlerMiddleware` — don't add + bare `try/except: pass` that swallows errors and returns a fake success. +- **Status-code discipline:** success bodies use the success helper (HTTP 200); auth/permission + denials use `get_unauthorized_response()` (403). Don't invent new shapes for these. +- **Keep views thin and consistent with siblings.** Reuse `CommonUtils`, `utils/karma.py`, mail + helpers, and `DiscordWebhooks` rather than duplicating logic inline. Side effects that exist in + sibling endpoints (e.g. firing `DiscordWebhooks.general_updates` after a mutation) should not be + silently dropped in a new handler for the same resource. +- **Every endpoint is annotated with `@extend_schema(tags=[...], ...)`** for drf-spectacular. New + endpoints must include it with the correct tag; otherwise the generated OpenAPI/`openapi.yaml` + drifts from reality. + +--- + +## 7. Serializer Rules + +- **Serializers are `ModelSerializer` with explicit `fields` lists** — never `fields = "__all__"`. + Adding a field to an existing serializer changes a published API contract: confirm the field is + safe to expose and that clients can handle it. +- **Never expose sensitive columns.** `User.password`, raw tokens, `ForgotPassword` internals, and + similar must never appear in a serializer `fields` list or a method field. Flag any serializer + that pulls these in. +- **Derived/related data uses `SerializerMethodField` or `source=` traversal** (the existing + convention). Keep query-bearing method fields backed by a prefetch (see §5). +- **Multi-write serializer logic must be wrapped in `transaction.atomic`** (the established pattern + for create/update that touches several tables). A multi-step write without a transaction risks + partial writes across the interlinked schema. +- **Don't bury authorization or heavy business rules inside serializers.** Permission and role + decisions belong in the view layer (decorators / `JWTUtils`), not in `validate_*`. + +--- + +## 8. Security Standards + +- **Authentication is `authentication_classes = [CustomizePermission]`.** Any new endpoint that is + not deliberately public must declare it. A view that reads user data without an auth class is a + data-leak bug — flag it as CRITICAL. +- **Authorization is enforced with the role decorators**: `@role_required([RoleType.X.value])`, + `RoleRequired`, or `@dynamic_role_required(type)`. Service-to-service endpoints use + `BackendApiKeyPermission` (the `Api-Key` header). A mutating or admin endpoint with no role/key + check is a privilege-escalation risk. +- **Trust the JWT, never the request body, for identity and roles.** Always derive the actor via + `JWTUtils.fetch_user_id` / `fetch_muid` / `fetch_role`. Code that reads `user_id`, `roles`, or + `admin` from `request.data`/query params to make an authorization or ownership decision is a + bypass — flag it. (Note the existing `request.data["admin"] = JWTUtils.fetch_user_id(request)` + pattern overrides client input with the trusted id; preserve that direction.) +- **Object-level ownership must be checked explicitly.** Role checks alone don't prove the actor + owns the record being mutated/read. For per-user resources, confirm the row belongs to the + JWT user (or an authorized role) before returning or modifying it. +- **Secrets come only from `decouple_config`.** No hardcoded keys, tokens, or credentials. + `SECRET_KEY` doubles as the JWT signing key — never log it, return it, or weaken the HS256 + verification (`verify=True`, explicit `algorithms=["HS256"]`). +- **Be deliberate about the permissive defaults.** `CORS_ALLOW_ALL_ORIGINS = True` and + `debug_toolbar` are present; do not widen exposure further (e.g. don't add `DEBUG=True` defaults, + don't print/log request bodies containing credentials, don't echo upstream secrets in responses). +- **Don't leak internals in error messages.** Returning `str(e)` is used in places, but new code + should avoid surfacing stack traces, SQL, or upstream secret-bearing payloads to clients. + +--- + +## 9. External Integration Rules + +- **Every outbound `requests` call must set `timeout=` and handle failure.** This is a real, + recurring gap in the codebase — many calls have no timeout, so a slow upstream (Wadhwani, KKEM, + QSEverse, Razorpay, Discord) can hang a worker. The auth proxy (`api/auth/auth_views.py`) is the + reference pattern: `timeout=30` plus explicit `requests.exceptions.Timeout` / + `RequestException` handling returning a `CustomResponse` failure. Require this for any new or + modified external call. +- **Never call `response.json()` without guarding for non-JSON / error payloads.** Check status and + shape before indexing into the response. +- **Payment (Razorpay) flows must verify signatures and treat secrets as confidential.** Don't log + payment payloads or secrets; don't trust client-reported payment status without server-side + verification. +- **`DiscordWebhooks.general_updates` and email sends are side effects** — a failure there should + not corrupt the primary transaction or mask the real result. Keep them after the core write. + +--- + +## 10. Migration & Schema-Change Safety + +- Because models are `managed = False`, **schema changes are made in the database / `schema.sql` / + `alter-scripts/`, not through Django migrations.** A PR that adds a model field but no + corresponding DB schema change will pass code review and then fail at runtime with an unknown + column — flag the missing schema artifact. +- **Renaming/removing a field or table is a breaking change** across serializers, filters, raw + references, and integrations. Require the cross-module trace (§3) and confirm backward + compatibility for in-flight clients before approving. +- Confirm existing-data compatibility for any new non-null column (it must have a DB-level default + or a backfill), since Django won't manage the column for you. + +--- + +## 11. Background Jobs (Celery) Rules + +- **Crons must be idempotent.** They run on a schedule (see `CELERY_BEAT_SCHEDULE`) and may + reprocess overlapping windows — re-running must not double-apply karma, double-transition status, + or duplicate rows. The intern crons (`mu_celery/intern_cron.py`) are the model: batch-fetch, set + membership for O(1) lookups, and `save(update_fields=[...])` for narrow writes. +- **Use `update_fields` on targeted status writes** to avoid clobbering concurrently-changed + columns. +- **There is no system-user convention for `updated_by` in crons** (documented limitation). Don't + invent one ad hoc; leave actor columns unchanged in background jobs unless the team adds a + convention. +- **Don't block the request path on slow work.** Long-running or external-API-heavy work belongs in + a Celery task, not a synchronous view. +- **Bulk operations** (`bulk_create`, `F()` updates) are the established pattern for multi-row karma + and wallet changes — prefer them over per-row loops for large sets, and keep wallet/karma changes + consistent between `add_karma` and `remove_karma`. + +--- + +## 12. Testing Expectations + +- **Tests use pytest + pytest-django**, `APIClient`, and fixtures in a local `conftest.py` + (`user_fixture`, `auth_client`, resource fixtures). Match this style; don't introduce + `unittest.TestCase` or a new harness. +- **Require tests for:** permission/visibility rules (owner-vs-public access is the existing focus, + see `projects/tests/`), karma/wallet mutations, serializer output contracts, and every bug fix + (a regression test that fails before the fix). +- **Be aware `force_authenticate` bypasses `CustomizePermission`/JWT.** Tests for role-gated + endpoints that rely on `JWTUtils.fetch_role` need a real signed token, not just + `force_authenticate`. Flag a test that claims to cover a role check but never exercises the JWT + path. +- **Do not demand tests for:** settings tweaks, schema/`alter-scripts` changes, `@extend_schema` + doc-only edits, or cosmetic refactors. +- Test behavior (response envelope, visibility, side effects), not private implementation details. + +--- + +## 13. Repository-Specific Anti-Patterns (flag these) + +1. **Using `User.objects` where suspended users must be included** (admin lookups, suspension + flows, FK checks). Use `User.every`. +2. **An outbound `requests` call with no `timeout=`** and no `Timeout`/`RequestException` handling. +3. **Returning a raw `Response`/`JsonResponse` from an API view** instead of `CustomResponse`. +4. **Adding a model field with no matching `schema.sql` / `alter-scripts` change** (relying on + Django migrations on a `managed = False` model). +5. **Deriving identity/roles from `request.data` or query params** instead of `JWTUtils`. +6. **A new view missing `authentication_classes` and/or a role/key decorator** on non-public data. +7. **A list endpoint serializing related/method fields without `select_related`/`prefetch_related`**, + or returning an unpaginated queryset. +8. **Re-implementing karma/wallet, pagination, CSV, or email logic inline** instead of reusing + `utils/karma.py`, `CommonUtils`, or `send_template_mail`. +9. **`fields = "__all__"`** or exposing `password`/tokens/secrets in a serializer. +10. **Swallowing exceptions to fake a success response**, defeating `UniversalErrorHandlerMiddleware`. +11. **Hardcoded secrets/URLs** instead of `decouple_config`. +12. **A new endpoint without `@extend_schema`**, drifting the OpenAPI contract. + +Note: duplicate `urlpatterns` entries mapping the same path to one view under different `name`s +(e.g. get/patch/delete for `/`) are an intentional convention here — do **not** flag +them as duplicates. + +--- + +## 14. PR Review Checklist + +**CRITICAL (block):** +- [ ] No missing `authentication_classes` / role / `Api-Key` check on non-public data. +- [ ] No identity/role/ownership decision based on client-supplied input. +- [ ] No exposed secrets, passwords, or tokens (code, logs, serializers, responses). +- [ ] No model/field change lacking a corresponding DB schema change (`managed = False`). +- [ ] No response-envelope break that would regress clients. + +**HIGH:** +- [ ] Correct `User.objects` vs `User.every` for the suspended-user case. +- [ ] Outbound `requests` have `timeout=` and failure handling. +- [ ] Karma/wallet, pagination, CSV, email reuse existing helpers and stay consistent. +- [ ] Crons remain idempotent and use `update_fields`. +- [ ] Cross-module impact (serializers, filters, integrations, tasks) accounted for. + +**MEDIUM:** +- [ ] List endpoints prefetch what serializers touch; no introduced N+1; pagination intact. +- [ ] Serializers expose only intended fields; multi-write wrapped in `transaction.atomic`. +- [ ] Tests added for permission/visibility rules, business logic, and bug fixes. +- [ ] `@extend_schema` present and correctly tagged. + +**LOW:** +- [ ] Naming, file placement, and enum usage (`utils/types.py`) match existing conventions. + +When commenting, name the exact file, the affected workflow, and the concrete failure scenario. +Skip style nits and anything already consistent with the patterns above. diff --git a/README.md b/README.md index 23bfd8907..86613bebf 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ pip install -r requirements.txt ### Set environment variables Create a .env file in the project root directory by copying .env.sample and replace with your values. +> [!NOTE] +> Apply [this migration script](https://gist.github.com/e3ob/9e1116996e1daec8df8701548eeaa528) before running the project + ### Run the Project ```commandline python manage.py runserver diff --git a/alter-scripts/alter-1.61.py b/alter-scripts/alter-1.61.py new file mode 100644 index 000000000..d22d3a947 --- /dev/null +++ b/alter-scripts/alter-1.61.py @@ -0,0 +1,47 @@ +import os +import sys +from pathlib import Path +from decouple import config +import django + +from connection import execute + +BASE_DIR = Path(__file__).resolve().parent.parent +os.chdir(BASE_DIR) +sys.path.append(str(BASE_DIR)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mulearnbackend.settings') +django.setup() + +DB_NAME = config('DATABASE_NAME') + + +def column_exists(column_name: str) -> bool: + # Guard against double-running the script on environments that already have the column + query = f""" + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = '{DB_NAME}' + AND TABLE_NAME = 'company_jobs' + AND COLUMN_NAME = '{column_name}'; + """ + result = execute(query) + return result and result[0][0] > 0 + + +def add_total_views_column(): + if not column_exists('total_views'): + execute( + """ +ALTER TABLE company_jobs + ADD COLUMN total_views INT NOT NULL DEFAULT 0; + """ + ) + print("[alter-1.61] Added column 'total_views' to 'company_jobs' table.") + else: + print("[alter-1.61] Column 'total_views' already exists in 'company_jobs' table.") + + +if __name__ == '__main__': + add_total_views_column() + execute("UPDATE system_setting SET value = '1.61', updated_at = now() WHERE `key` = 'db.version';") + print("[alter-1.61] Updated database version to 1.61.") diff --git a/alter-scripts/alter-1.62.py b/alter-scripts/alter-1.62.py new file mode 100644 index 000000000..cf3fbc341 --- /dev/null +++ b/alter-scripts/alter-1.62.py @@ -0,0 +1,68 @@ +import os +import sys +from pathlib import Path +from decouple import config +import django + +from connection import execute + +BASE_DIR = Path(__file__).resolve().parent.parent +os.chdir(BASE_DIR) +sys.path.append(str(BASE_DIR)) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mulearnbackend.settings') +django.setup() + +DB_NAME = config('DATABASE_NAME') + + +def table_exists(table_name: str) -> bool: + query = f""" + SELECT COUNT(*) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = '{DB_NAME}' + AND TABLE_NAME = '{table_name}'; + """ + result = execute(query) + return result and result[0][0] > 0 + + +def create_comic_comment_table(): + if not table_exists('comic_comment'): + execute( + """ + CREATE TABLE `comic_comment` ( + `id` varchar(36) NOT NULL, + `comic_id` varchar(36) NOT NULL, + `chapter_id` varchar(36) DEFAULT NULL, + `parent_id` varchar(36) DEFAULT NULL, + `user_id` varchar(36) NOT NULL, + `message` text NOT NULL, + `deleted_at` datetime DEFAULT NULL, + `deleted_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_comic_comment_comic` (`comic_id`,`created_at`), + KEY `idx_comic_comment_chapter` (`chapter_id`,`created_at`), + KEY `idx_comic_comment_parent` (`parent_id`), + KEY `idx_comic_comment_user` (`user_id`), + CONSTRAINT `fk_comic_comment_ref_comic_id` FOREIGN KEY (`comic_id`) REFERENCES `comic` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `comic_comment` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_del_by` FOREIGN KEY (`deleted_by`) REFERENCES `user` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_comic_comment_ref_upd_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_cre_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + """ + ) + print("[alter-1.62] Created table 'comic_comment'.") + else: + print("[alter-1.62] Table 'comic_comment' already exists.") + + +if __name__ == '__main__': + create_comic_comment_table() + execute("UPDATE system_setting SET value = '1.62', updated_at = now() WHERE `key` = 'db.version';") + print("[alter-1.62] Updated database version to 1.62.") diff --git a/api/auth/__init__.py b/api/auth/__init__.py new file mode 100644 index 000000000..457b9fc3d --- /dev/null +++ b/api/auth/__init__.py @@ -0,0 +1 @@ +# Auth proxy module - forwards requests to auth server diff --git a/api/auth/auth_views.py b/api/auth/auth_views.py new file mode 100644 index 000000000..5bd8f2d1b --- /dev/null +++ b/api/auth/auth_views.py @@ -0,0 +1,231 @@ +import decouple +import requests +from rest_framework.views import APIView + +from utils.response import CustomResponse +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s + + +AUTH_DOMAIN = decouple.config("AUTH_DOMAIN") + + +class GoogleMobileAuthProxyAPI(APIView): + """ + Proxy endpoint for Google mobile authentication. + Forwards requests to the auth server and returns the response. + """ + + @extend_schema(tags=['Auth'], description="Create Google Mobile Auth Proxy.", + responses={200: inline_serializer("AuthTokenResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.DictField( + help_text="Upstream auth payload (access_token, refresh_token, user info, etc.)" + ), + })}, + ) + def post(self, request): + id_token = request.data.get("id_token") or request.data.get("idToken") + + if not id_token: + return CustomResponse( + general_message="ID token is required" + ).get_failure_response() + + try: + response = requests.post( + f"{AUTH_DOMAIN}/api/v1/auth/google-mobile/", + json={"id_token": id_token}, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + + data = response.json() + + if data.get("hasError"): + return CustomResponse( + general_message=data.get("message", {}).get("general", ["Authentication failed"])[0] + ).get_failure_response() + + return CustomResponse( + general_message="Access Granted", + response=data.get("response"), + ).get_success_response() + + except requests.exceptions.Timeout: + return CustomResponse( + general_message="Authentication server timeout" + ).get_failure_response() + except requests.exceptions.RequestException as e: + return CustomResponse( + general_message=f"Authentication server error: {str(e)}" + ).get_failure_response() + + +class AppleMobileAuthProxyAPI(APIView): + """ + Proxy endpoint for Apple mobile authentication. + Forwards requests to the auth server and returns the response. + """ + + @extend_schema(tags=['Auth'], description="Create Apple Mobile Auth Proxy.", + responses={200: inline_serializer("AuthAppleTokenResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.DictField( + help_text="Upstream auth payload (access_token, refresh_token, user info, etc.)" + ), + })}, + ) + def post(self, request): + identity_token = request.data.get("identity_token") or request.data.get("identityToken") + email = request.data.get("email") + + if not identity_token: + return CustomResponse( + general_message="Identity token is required" + ).get_failure_response() + + try: + payload = {"identity_token": identity_token} + if email: + payload["email"] = email + + response = requests.post( + f"{AUTH_DOMAIN}/api/v1/auth/apple-mobile/", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + + data = response.json() + + if data.get("hasError"): + return CustomResponse( + general_message=data.get("message", {}).get("general", ["Authentication failed"])[0] + ).get_failure_response() + + return CustomResponse( + general_message="Access Granted", + response=data.get("response"), + ).get_success_response() + + except requests.exceptions.Timeout: + return CustomResponse( + general_message="Authentication server timeout" + ).get_failure_response() + except requests.exceptions.RequestException as e: + return CustomResponse( + general_message=f"Authentication server error: {str(e)}" + ).get_failure_response() + + +class UserAuthenticationProxyAPI(APIView): + """ + Proxy endpoint for email/password user authentication. + Forwards requests to the auth server and returns the response. + """ + + @extend_schema(tags=['Auth'], description="Create User Authentication Proxy.", + responses={200: inline_serializer("AuthUserAuthResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.DictField( + help_text="Upstream auth payload (access_token, refresh_token, user info, etc.)" + ), + })}, + ) + def post(self, request): + email = request.data.get("emailOrMuid") + password = request.data.get("password") + + if not email or not password: + return CustomResponse( + general_message="Email and password are required" + ).get_failure_response() + + try: + response = requests.post( + f"{AUTH_DOMAIN}/api/v1/auth/user-authentication/", + json={"emailOrMuid": email, "password": password}, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + + data = response.json() + + if data.get("hasError"): + return CustomResponse( + general_message=data.get("message", {}).get("general", ["Authentication failed"])[0] + ).get_failure_response() + + return CustomResponse( + general_message="Access Granted", + response=data.get("response"), + ).get_success_response() + + except requests.exceptions.Timeout: + return CustomResponse( + general_message="Authentication server timeout" + ).get_failure_response() + except requests.exceptions.RequestException as e: + return CustomResponse( + general_message=f"Authentication server error: {str(e)}" + ).get_failure_response() + + +class RefreshTokenProxyAPI(APIView): + """ + Proxy endpoint for token refresh. + Forwards requests to the auth server and returns fresh tokens. + """ + + @extend_schema(tags=['Auth'], description="Create Refresh Token Proxy.", + responses={200: inline_serializer("AuthRefreshTokenResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.DictField( + help_text="Upstream token payload (new access_token, refresh_token, expiry, etc.)" + ), + })}, + ) + def post(self, request): + refresh_token = request.data.get("refreshToken") or request.data.get("refresh_token") + + if not refresh_token: + return CustomResponse( + general_message="Refresh token is required" + ).get_failure_response() + + try: + response = requests.post( + f"{AUTH_DOMAIN}/api/v1/auth/get-access-token/", + json={"refreshToken": refresh_token}, + headers={"Content-Type": "application/json"}, + timeout=30, + ) + + data = response.json() + + if data.get("hasError"): + return CustomResponse( + general_message=data.get("message", {}).get("general", ["Token refresh failed"])[0] + ).get_failure_response() + + return CustomResponse( + response=data.get("response"), + ).get_success_response() + + except requests.exceptions.Timeout: + return CustomResponse( + general_message="Authentication server timeout" + ).get_failure_response() + except requests.exceptions.RequestException as e: + return CustomResponse( + general_message=f"Authentication server error: {str(e)}" + ).get_failure_response() diff --git a/api/auth/urls.py b/api/auth/urls.py new file mode 100644 index 000000000..2a7a97db1 --- /dev/null +++ b/api/auth/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import auth_views + +urlpatterns = [ + path('user-authentication/', auth_views.UserAuthenticationProxyAPI.as_view(), name='user_auth_proxy'), + path('google-mobile/', auth_views.GoogleMobileAuthProxyAPI.as_view(), name='google_mobile_proxy'), + path('apple-mobile/', auth_views.AppleMobileAuthProxyAPI.as_view(), name='apple_mobile_proxy'), + path('refresh-token/', auth_views.RefreshTokenProxyAPI.as_view(), name='refresh_token_proxy'), +] diff --git a/api/calendar/__init__.py b/api/calendar/__init__.py new file mode 100644 index 000000000..e3a189f81 --- /dev/null +++ b/api/calendar/__init__.py @@ -0,0 +1 @@ +# Calendar API module diff --git a/api/calendar/calendar_view.py b/api/calendar/calendar_view.py new file mode 100644 index 000000000..ef840cc7c --- /dev/null +++ b/api/calendar/calendar_view.py @@ -0,0 +1,448 @@ +from django.utils import timezone +from django.db.models import Prefetch, Q + +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes + +from db.mentor import MentorshipSession, MentorshipSessionUserLink +from db.events import Event +from db.task import InterestGroup +from db.organization import Organization +from utils.response import CustomResponse +from utils.types import OrganizationType +from . import serializers +from api.dashboard.events.serializers import EventCalendarItemSerializer + + +def _parse_month(month_str): + """ + Parse a 'YYYY-MM' string and return (start, end) datetime bounds for that + month, or (None, None) if the string is absent / malformed. + """ + if not month_str: + return None, None + try: + import datetime + year, month = map(int, month_str.split('-')) + start = timezone.datetime(year, month, 1, tzinfo=timezone.utc) + # First day of next month + if month == 12: + end = timezone.datetime(year + 1, 1, 1, tzinfo=timezone.utc) + else: + end = timezone.datetime(year, month + 1, 1, tzinfo=timezone.utc) + return start, end + except (ValueError, AttributeError): + return None, None + + +def _group_sessions(qs): + """Split a MentorshipSession queryset into upcoming / ongoing / completed buckets.""" + now = timezone.now() + upcoming, ongoing, completed = [], [], [] + for session in qs: + if session.status == MentorshipSession.Status.COMPLETED: + completed.append(session) + elif session.starts_at > now: + upcoming.append(session) + else: + ongoing.append(session) + return upcoming, ongoing, completed + + +def _group_events(qs): + """Split an Event queryset into upcoming / ongoing / completed buckets.""" + upcoming, ongoing, completed = [], [], [] + for event in qs: + if event.status == Event.Status.COMPLETED: + completed.append(event) + elif event.status == Event.Status.ONGOING: + ongoing.append(event) + else: + upcoming.append(event) + return upcoming, ongoing, completed + + +# --------------------------------------------------------------------------- +# Session calendar helpers +# --------------------------------------------------------------------------- + +SESSION_PREFETCH = Prefetch( + 'participant_links', + queryset=MentorshipSessionUserLink.objects.select_related('user'), +) + + +class IGMentorSessionCalendar(APIView): + @extend_schema( + tags=['Calendar'], + description=( + "Calendar view of mentorship sessions for a specific Interest Group. " + "Returns sessions grouped as upcoming, ongoing, and completed. " + "Filter by month using the `month` query parameter (format: YYYY-MM)." + ), + parameters=[ + OpenApiParameter('month', OpenApiTypes.STR, description='Filter by month (YYYY-MM)', required=False), + OpenApiParameter('status', OpenApiTypes.STR, description='Filter by status: SCHEDULED, COMPLETED, CANCELLED', required=False), + ], + responses={200: serializers.MentorshipSessionCalendarSerializer(many=True)}, + ) + def get(self, request, ig_id): + ig = InterestGroup.objects.filter(id=ig_id.strip()).first() + if not ig: + return CustomResponse(general_message="Interest Group not found").get_failure_response() + + qs = MentorshipSession.objects.filter( + session_type=MentorshipSession.SessionType.IG_SESSION, + entity_id=ig_id, + is_deleted=False, + ).prefetch_related(SESSION_PREFETCH).order_by('starts_at') + + # Optional month filter + month_str = request.query_params.get('month') + start, end = _parse_month(month_str) + if start and end: + qs = qs.filter(starts_at__gte=start, starts_at__lt=end) + + # Optional status filter + status_filter = request.query_params.get('status') + if status_filter: + qs = qs.filter(status=status_filter.upper()) + + upcoming, ongoing, completed = _group_sessions(qs) + + def serialize(sessions): + return serializers.MentorshipSessionCalendarSerializer(sessions, many=True).data + + return CustomResponse(response={ + 'upcoming': serialize(upcoming), + 'ongoing': serialize(ongoing), + 'completed': serialize(completed), + }).get_success_response() + + +class CampusMentorSessionCalendar(APIView): + @extend_schema( + tags=['Calendar'], + description=( + "Calendar view of mentorship sessions for a specific campus. " + "Returns sessions grouped as upcoming, ongoing, and completed. " + "Filter by month using the `month` query parameter (format: YYYY-MM)." + ), + parameters=[ + OpenApiParameter('month', OpenApiTypes.STR, description='Filter by month (YYYY-MM)', required=False), + OpenApiParameter('status', OpenApiTypes.STR, description='Filter by status: SCHEDULED, COMPLETED, CANCELLED', required=False), + ], + responses={200: serializers.MentorshipSessionCalendarSerializer(many=True)}, + ) + def get(self, request, campus_id): + campus = Organization.objects.filter( + id=campus_id.strip(), org_type=OrganizationType.COLLEGE.value + ).first() + if not campus: + return CustomResponse(general_message="Campus not found").get_failure_response() + + qs = MentorshipSession.objects.filter( + session_type=MentorshipSession.SessionType.CAMPUS_SESSION, + entity_id=campus_id, + is_deleted=False, + ).prefetch_related(SESSION_PREFETCH).order_by('starts_at') + + month_str = request.query_params.get('month') + start, end = _parse_month(month_str) + if start and end: + qs = qs.filter(starts_at__gte=start, starts_at__lt=end) + + status_filter = request.query_params.get('status') + if status_filter: + qs = qs.filter(status=status_filter.upper()) + + upcoming, ongoing, completed = _group_sessions(qs) + + def serialize(sessions): + return serializers.MentorshipSessionCalendarSerializer(sessions, many=True).data + + return CustomResponse(response={ + 'upcoming': serialize(upcoming), + 'ongoing': serialize(ongoing), + 'completed': serialize(completed), + }).get_success_response() + + +# --------------------------------------------------------------------------- +# Event calendar views +# --------------------------------------------------------------------------- + +EVENT_STATUSES_VISIBLE = [ + Event.Status.PUBLISHED, + Event.Status.ONGOING, + Event.Status.COMPLETED, +] + +EVENT_PREFETCH = Prefetch( + 'scope_ig', + to_attr='_scope_ig_cached', +) + + +class EventCalendar(APIView): + @extend_schema( + tags=['Calendar'], + description=( + "Global platform events calendar. Returns events grouped as upcoming, ongoing, " + "and completed. Supports optional filtering by month (YYYY-MM) and scope." + ), + parameters=[ + OpenApiParameter('month', OpenApiTypes.STR, description='Filter by month (YYYY-MM)', required=False), + OpenApiParameter('scope', OpenApiTypes.STR, description='Filter by scope: ig, campus, global, company', required=False), + OpenApiParameter('status', OpenApiTypes.STR, description='Filter by status: ongoing, upcoming, completed', required=False), + ], + responses={200: EventCalendarItemSerializer(many=True)}, + ) + def get(self, request): + qs = Event.objects.filter( + status__in=EVENT_STATUSES_VISIBLE, + deleted_at__isnull=True, + ).select_related('category', 'organiser_ig', 'organiser_org').order_by('start_datetime') + + month_str = request.query_params.get('month') + start, end = _parse_month(month_str) + if start and end: + qs = qs.filter(start_datetime__gte=start, start_datetime__lt=end) + + scope_filter = request.query_params.get('scope') + if scope_filter: + qs = qs.filter(scope=scope_filter.lower()) + + status_filter = request.query_params.get('status') + if status_filter: + sf = status_filter.lower() + if sf == 'upcoming': + qs = qs.filter(status=Event.Status.PUBLISHED) + elif sf == 'ongoing': + qs = qs.filter(status=Event.Status.ONGOING) + elif sf == 'completed': + qs = qs.filter(status=Event.Status.COMPLETED) + + upcoming, ongoing, completed = _group_events(qs) + + def serialize(events): + return EventCalendarItemSerializer(events, many=True).data + + return CustomResponse(response={ + 'upcoming': serialize(upcoming), + 'ongoing': serialize(ongoing), + 'completed': serialize(completed), + }).get_success_response() + + +class IGEventCalendar(APIView): + @extend_schema( + tags=['Calendar'], + description=( + "Events calendar scoped to a specific Interest Group. Returns events grouped as " + "upcoming, ongoing, and completed. Supports optional month filtering." + ), + parameters=[ + OpenApiParameter('month', OpenApiTypes.STR, description='Filter by month (YYYY-MM)', required=False), + OpenApiParameter('status', OpenApiTypes.STR, description='Filter by status: ongoing, upcoming, completed', required=False), + ], + responses={200: EventCalendarItemSerializer(many=True)}, + ) + def get(self, request, ig_id): + ig = InterestGroup.objects.filter(id=ig_id.strip()).first() + if not ig: + return CustomResponse(general_message="Interest Group not found").get_failure_response() + + qs = Event.objects.filter( + status__in=EVENT_STATUSES_VISIBLE, + deleted_at__isnull=True, + ).filter( + Q(scope_ig=ig) | Q(organiser_ig=ig) + ).select_related('category', 'organiser_ig', 'organiser_org').order_by('start_datetime').distinct() + + month_str = request.query_params.get('month') + start, end = _parse_month(month_str) + if start and end: + qs = qs.filter(start_datetime__gte=start, start_datetime__lt=end) + + status_filter = request.query_params.get('status') + if status_filter: + sf = status_filter.lower() + if sf == 'upcoming': + qs = qs.filter(status=Event.Status.PUBLISHED) + elif sf == 'ongoing': + qs = qs.filter(status=Event.Status.ONGOING) + elif sf == 'completed': + qs = qs.filter(status=Event.Status.COMPLETED) + + upcoming, ongoing, completed = _group_events(qs) + + def serialize(events): + return EventCalendarItemSerializer(events, many=True).data + + return CustomResponse(response={ + 'upcoming': serialize(upcoming), + 'ongoing': serialize(ongoing), + 'completed': serialize(completed), + }).get_success_response() + + +# --------------------------------------------------------------------------- +# Company Session Calendar +# --------------------------------------------------------------------------- + +class CompanySessionCalendar(APIView): + @extend_schema( + tags=['Calendar'], + description=( + "Calendar view of mentorship sessions for a specific company org. " + "Returns sessions grouped as upcoming, ongoing, and completed. " + "Filter by month using the `month` query parameter (format: YYYY-MM)." + ), + parameters=[ + OpenApiParameter('month', OpenApiTypes.STR, description='Filter by month (YYYY-MM)', required=False), + OpenApiParameter('status', OpenApiTypes.STR, description='Filter by status: SCHEDULED, COMPLETED, CANCELLED', required=False), + ], + responses={200: serializers.MentorshipSessionCalendarSerializer(many=True)}, + ) + def get(self, request, company_org_id): + company_org = Organization.objects.filter( + id=company_org_id.strip(), org_type=OrganizationType.COMPANY.value + ).first() + if not company_org: + return CustomResponse(general_message="Company org not found").get_failure_response() + + qs = MentorshipSession.objects.filter( + session_type=MentorshipSession.SessionType.COMPANY_SESSION, + entity_id=company_org_id, + is_deleted=False, + ).prefetch_related(SESSION_PREFETCH).order_by('starts_at') + + month_str = request.query_params.get('month') + start, end = _parse_month(month_str) + if start and end: + qs = qs.filter(starts_at__gte=start, starts_at__lt=end) + + status_filter = request.query_params.get('status') + if status_filter: + qs = qs.filter(status=status_filter.upper()) + + upcoming, ongoing, completed = _group_sessions(qs) + + def serialize(sessions): + return serializers.MentorshipSessionCalendarSerializer(sessions, many=True).data + + return CustomResponse(response={ + 'upcoming': serialize(upcoming), + 'ongoing': serialize(ongoing), + 'completed': serialize(completed), + }).get_success_response() + + +class CampusEventCalendar(APIView): + @extend_schema( + tags=['Calendar'], + description=( + "Events calendar scoped to a specific Campus. Returns events grouped as " + "upcoming, ongoing, and completed. Supports optional month filtering." + ), + parameters=[ + OpenApiParameter('month', OpenApiTypes.STR, description='Filter by month (YYYY-MM)', required=False), + OpenApiParameter('status', OpenApiTypes.STR, description='Filter by status: ongoing, upcoming, completed', required=False), + ], + responses={200: EventCalendarItemSerializer(many=True)}, + ) + def get(self, request, campus_id): + campus = Organization.objects.filter( + id=campus_id.strip(), org_type=OrganizationType.COLLEGE.value + ).first() + if not campus: + return CustomResponse(general_message="Campus not found").get_failure_response() + + qs = Event.objects.filter( + status__in=EVENT_STATUSES_VISIBLE, + deleted_at__isnull=True, + ).filter( + Q(scope_org=campus) | Q(organiser_org=campus) + ).select_related('category', 'organiser_ig', 'organiser_org').order_by('start_datetime').distinct() + + month_str = request.query_params.get('month') + start, end = _parse_month(month_str) + if start and end: + qs = qs.filter(start_datetime__gte=start, start_datetime__lt=end) + + status_filter = request.query_params.get('status') + if status_filter: + sf = status_filter.lower() + if sf == 'upcoming': + qs = qs.filter(status=Event.Status.PUBLISHED) + elif sf == 'ongoing': + qs = qs.filter(status=Event.Status.ONGOING) + elif sf == 'completed': + qs = qs.filter(status=Event.Status.COMPLETED) + + upcoming, ongoing, completed = _group_events(qs) + + def serialize(events): + return EventCalendarItemSerializer(events, many=True).data + + return CustomResponse(response={ + 'upcoming': serialize(upcoming), + 'ongoing': serialize(ongoing), + 'completed': serialize(completed), + }).get_success_response() + + +class CompanyEventCalendar(APIView): + @extend_schema( + tags=['Calendar'], + description=( + "Events calendar scoped to a specific Company. Returns events grouped as " + "upcoming, ongoing, and completed. Supports optional month filtering." + ), + parameters=[ + OpenApiParameter('month', OpenApiTypes.STR, description='Filter by month (YYYY-MM)', required=False), + OpenApiParameter('status', OpenApiTypes.STR, description='Filter by status: ongoing, upcoming, completed', required=False), + ], + responses={200: EventCalendarItemSerializer(many=True)}, + ) + def get(self, request, company_id): + company = Organization.objects.filter( + id=company_id.strip(), org_type=OrganizationType.COMPANY.value + ).first() + if not company: + return CustomResponse(general_message="Company not found").get_failure_response() + + qs = Event.objects.filter( + status__in=EVENT_STATUSES_VISIBLE, + deleted_at__isnull=True, + ).filter( + Q(scope_org=company) | Q(organiser_org=company) + ).select_related('category', 'organiser_ig', 'organiser_org').order_by('start_datetime').distinct() + + month_str = request.query_params.get('month') + start, end = _parse_month(month_str) + if start and end: + qs = qs.filter(start_datetime__gte=start, start_datetime__lt=end) + + status_filter = request.query_params.get('status') + if status_filter: + sf = status_filter.lower() + if sf == 'upcoming': + qs = qs.filter(status=Event.Status.PUBLISHED) + elif sf == 'ongoing': + qs = qs.filter(status=Event.Status.ONGOING) + elif sf == 'completed': + qs = qs.filter(status=Event.Status.COMPLETED) + + upcoming, ongoing, completed = _group_events(qs) + + def serialize(events): + return EventCalendarItemSerializer(events, many=True).data + + return CustomResponse(response={ + 'upcoming': serialize(upcoming), + 'ongoing': serialize(ongoing), + 'completed': serialize(completed), + }).get_success_response() diff --git a/api/calendar/dashboard_calendar_view.py b/api/calendar/dashboard_calendar_view.py new file mode 100644 index 000000000..6fce29b1e --- /dev/null +++ b/api/calendar/dashboard_calendar_view.py @@ -0,0 +1,393 @@ +""" +Unified Dashboard Calendar API +────────────────────────────── +Single endpoint that returns events and mentor sessions based on the +caller's role (derived from the JWT token in the Authorization header). + +Role-based rules: + Mentor → global + IG + campus + company events + mentor sessions + Student → global + IG + campus + company events + Enabler → global + IG + campus + company events + Unauthenticated → global events + all IG-scoped events +""" + +from datetime import datetime, timedelta + +from django.db.models import Prefetch, Q +from django.utils import timezone + +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes + +from db.events import Event +from db.mentor import MentorshipSession, MentorshipSessionUserLink +from db.task import UserIgLink +from db.organization import UserOrganizationLink +from db.user import UserMentor + +from utils.permission import JWTUtils, CustomizePermission +from utils.response import CustomResponse +from utils.types import RoleType + +from . import serializers as calendar_serializers +from api.dashboard.events.serializers import EventCalendarItemSerializer + + +# ─────────────────────────────────────────────────────────────── +# Constants +# ─────────────────────────────────────────────────────────────── + +EVENT_STATUSES_VISIBLE = [ + Event.Status.PUBLISHED, + Event.Status.ONGOING, + Event.Status.COMPLETED, +] + +SESSION_PREFETCH = Prefetch( + 'participant_links', + queryset=MentorshipSessionUserLink.objects.select_related('user'), +) + +MAX_DATE_RANGE_DAYS = 93 + + +# ─────────────────────────────────────────────────────────────── +# Helpers +# ─────────────────────────────────────────────────────────────── + +def _get_viewer_id(request): + """Safely extract user_id from JWT; returns None if unauthenticated.""" + if JWTUtils.is_logged_in(request): + try: + return JWTUtils.fetch_user_id(request) + except Exception: + pass + return None + + +def _get_viewer_roles(request): + """Safely extract roles from JWT; returns empty list if unauthenticated.""" + if JWTUtils.is_logged_in(request): + try: + return JWTUtils.fetch_role(request) + except Exception: + pass + return [] + + +def _detect_user_role(request, user_id): + """ + Determine the caller's primary role for calendar purposes. + Priority: mentor > enabler > student > None + """ + if not user_id: + return None + + roles = _get_viewer_roles(request) + + # Check if user is a mentor (via JWT role OR approved UserMentor record) + if RoleType.MENTOR.value in roles: + return 'mentor' + + # Also check UserMentor table for approved mentors + if UserMentor.objects.filter( + user_id=user_id, + status=UserMentor.Status.APPROVED, + ).exists(): + return 'mentor' + + if RoleType.ENABLER.value in roles: + return 'enabler' + + if RoleType.STUDENT.value in roles: + return 'student' + + # Fallback: treat any authenticated user as student-level + return 'student' + + +def _get_user_ig_ids(user_id): + """Get list of IG IDs the user is linked to.""" + if not user_id: + return [] + return list( + UserIgLink.objects.filter(user_id=user_id) + .values_list('ig_id', flat=True) + ) + + +def _get_user_org_ids(user_id): + """Get list of Organisation IDs the user is linked to (verified).""" + if not user_id: + return [] + return list( + UserOrganizationLink.objects.filter(user_id=user_id, verified=True) + .values_list('org_id', flat=True) + ) + + +def _build_event_filter(user_role, user_ig_ids, user_org_ids): + """ + Build Q filter for events based on the user's role. + + Campus events → only visible to users in that specific campus + IG events → only visible to students in that specific IG + Global events → visible to everyone + Company events → visible to everyone + """ + # Global + Company events are always visible to everyone + q = Q(scope=Event.Scope.GLOBAL) | Q(scope=Event.Scope.COMPANY) + + if user_role is None: + # Unauthenticated: global + company + ALL IG-scoped events + return q | Q(scope=Event.Scope.IG) + + # Authenticated users see their own IG events + if user_ig_ids: + q |= Q(scope=Event.Scope.IG, scope_ig_id__in=user_ig_ids) + + # Authenticated users see their own campus events + if user_org_ids: + q |= Q(scope=Event.Scope.CAMPUS, scope_org_id__in=user_org_ids) + + # Campus-IG scope: user in both org AND ig + if user_org_ids and user_ig_ids: + q |= Q( + scope=Event.Scope.CAMPUS_IG, + scope_org_id__in=user_org_ids, + scope_ig_id__in=user_ig_ids, + ) + + return q + + +# ─────────────────────────────────────────────────────────────── +# Main View +# ─────────────────────────────────────────────────────────────── + +class DashboardCalendarAPI(APIView): + """ + GET /calendar/dashboard/ + Unified calendar endpoint. Returns events and sessions relevant + to the caller based on their JWT token role. + """ + + @extend_schema( + tags=['Calendar'], + description=( + "Unified dashboard calendar. Returns events and mentor sessions " + "based on the caller's role (detected from JWT token). " + "Supports date-range filtering via start_date and end_date." + ), + parameters=[ + OpenApiParameter( + 'month', OpenApiTypes.STR, + description='Optional. Filter by month name. Mutually exclusive with start_date/end_date.', + required=False, + ), + OpenApiParameter( + 'year', OpenApiTypes.INT, + description='Optional. Used with month filter (defaults to current year).', + required=False, + ), + OpenApiParameter( + 'start_date', OpenApiTypes.DATE, + description='Start of date range (YYYY-MM-DD). Required if month is not provided.', + required=False, + ), + OpenApiParameter( + 'end_date', OpenApiTypes.DATE, + description='End of date range (YYYY-MM-DD). Required if month is not provided.', + required=False, + ), + OpenApiParameter( + 'status', OpenApiTypes.STR, + description='Optional. Filter by status: upcoming, ongoing, completed.', + required=False, + ), + ], + responses={200: EventCalendarItemSerializer(many=True)}, + ) + def get(self, request): + # ── 0. Strict Authentication Check ─────────────────────── + auth_header = request.META.get('HTTP_AUTHORIZATION') + if auth_header: + try: + JWTUtils.is_jwt_authenticated(request) + except Exception: + return CustomResponse( + general_message='Invalid or expired token.' + ).get_failure_response(http_status_code=401) + + # ── 1. Parse & validate date range ─────────────────────── + start_date_str = request.query_params.get('start_date') + end_date_str = request.query_params.get('end_date') + month_str = request.query_params.get('month') + year_str = request.query_params.get('year') + + if month_str: + month_str = month_str.lower().strip() + month_map = { + 'january': 1, 'jan': 1, 'february': 2, 'feb': 2, + 'march': 3, 'mar': 3, 'april': 4, 'apr': 4, + 'may': 5, 'june': 6, 'jun': 6, 'july': 7, 'jul': 7, + 'august': 8, 'aug': 8, 'september': 9, 'sep': 9, + 'october': 10, 'oct': 10, 'november': 11, 'nov': 11, + 'december': 12, 'dec': 12 + } + if month_str not in month_map: + return CustomResponse( + general_message='Invalid month provided.' + ).get_failure_response() + + month_int = month_map[month_str] + year_int = int(year_str) if year_str and year_str.isdigit() else timezone.now().year + + try: + start_dt = datetime(year_int, month_int, 1) + if month_int == 12: + end_dt = datetime(year_int + 1, 1, 1) + else: + end_dt = datetime(year_int, month_int + 1, 1) + start_dt = timezone.make_aware(start_dt, timezone.utc) + end_dt = timezone.make_aware(end_dt, timezone.utc) + except ValueError: + return CustomResponse( + general_message='Invalid year provided.' + ).get_failure_response() + + elif start_date_str and end_date_str: + try: + start_dt = datetime.strptime(start_date_str, '%Y-%m-%d') + end_dt = datetime.strptime(end_date_str, '%Y-%m-%d') + # Make end_date inclusive + end_dt = end_dt + timedelta(days=1) + start_dt = timezone.make_aware(start_dt, timezone.utc) + end_dt = timezone.make_aware(end_dt, timezone.utc) + except ValueError: + return CustomResponse( + general_message='Invalid date format. Use YYYY-MM-DD.' + ).get_failure_response() + + if start_dt >= end_dt: + return CustomResponse( + general_message='start_date must be before or equal to end_date.' + ).get_failure_response() + + if (end_dt - start_dt).days > MAX_DATE_RANGE_DAYS: + return CustomResponse( + general_message=f'Date range must not exceed {MAX_DATE_RANGE_DAYS} days.' + ).get_failure_response() + else: + return CustomResponse( + general_message='Please provide either (month) OR (start_date and end_date).' + ).get_failure_response() + + # ── 2. Detect caller context ───────────────────────────── + user_id = _get_viewer_id(request) + user_role = _detect_user_role(request, user_id) + user_ig_ids = _get_user_ig_ids(user_id) + user_org_ids = _get_user_org_ids(user_id) + + # ── 3. Build events queryset ───────────────────────────── + event_scope_filter = _build_event_filter(user_role, user_ig_ids, user_org_ids) + + events_base_qs = ( + Event.objects.filter( + status__in=EVENT_STATUSES_VISIBLE, + deleted_at__isnull=True, + ) + .filter(event_scope_filter) + .filter( + start_datetime__lt=end_dt, + end_datetime__gt=start_dt, + ) + .select_related('category', 'organiser_ig', 'organiser_org') + .order_by('start_datetime') + .distinct() + ) + + upcoming_events, ongoing_events, completed_events = [], [], [] + + # Optional status filter for events + status_filter = request.query_params.get('status') + sf = status_filter.lower() if status_filter else None + + if not sf or sf == 'upcoming': + upcoming_events = list(events_base_qs.filter(status=Event.Status.PUBLISHED)[:100]) + if not sf or sf == 'ongoing': + ongoing_events = list(events_base_qs.filter(status=Event.Status.ONGOING)[:100]) + if not sf or sf == 'completed': + completed_events = list(events_base_qs.filter(status=Event.Status.COMPLETED)[:100]) + + # ── 4. Build sessions queryset (mentor only) ───────────── + sessions_data = {'upcoming': [], 'ongoing': [], 'completed': []} + + if user_role == 'mentor' and user_id: + # Get all sessions where this user is a participant (as MENTOR) + session_ids = MentorshipSessionUserLink.objects.filter( + user_id=user_id, + participant_role=MentorshipSessionUserLink.ParticipantRole.MENTOR, + ).values_list('session_id', flat=True) + + sessions_base_qs = ( + MentorshipSession.objects.filter( + id__in=session_ids, + is_deleted=False, + ) + .filter( + starts_at__lt=end_dt, + ends_at__gt=start_dt, + ) + .prefetch_related(SESSION_PREFETCH) + .order_by('starts_at') + ) + + now = timezone.now() + upcoming_sessions, ongoing_sessions, completed_sessions = [], [], [] + + sf_session = status_filter.lower() if status_filter else None + + if not sf_session or sf_session == 'upcoming': + upcoming_sessions = list(sessions_base_qs.filter( + status=MentorshipSession.Status.SCHEDULED, + starts_at__gt=now + )[:100]) + + if not sf_session or sf_session == 'ongoing': + ongoing_sessions = list(sessions_base_qs.filter( + status=MentorshipSession.Status.SCHEDULED, + starts_at__lte=now + )[:100]) + + if not sf_session or sf_session == 'completed': + completed_sessions = list(sessions_base_qs.filter( + status__in=[ + MentorshipSession.Status.COMPLETED, + MentorshipSession.Status.CANCELLED, + MentorshipSession.Status.REJECTED + ] + )[:100]) + + sessions_data = { + 'upcoming': calendar_serializers.MentorshipSessionCalendarSerializer( + upcoming_sessions, many=True + ).data, + 'ongoing': calendar_serializers.MentorshipSessionCalendarSerializer( + ongoing_sessions, many=True + ).data, + 'completed': calendar_serializers.MentorshipSessionCalendarSerializer( + completed_sessions, many=True + ).data, + } + + # ── 5. Return combined response ────────────────────────── + return CustomResponse(response={ + 'events': { + 'upcoming': EventCalendarItemSerializer(upcoming_events, many=True).data, + 'ongoing': EventCalendarItemSerializer(ongoing_events, many=True).data, + 'completed': EventCalendarItemSerializer(completed_events, many=True).data, + }, + 'sessions': sessions_data, + }).get_success_response() diff --git a/api/calendar/serializers.py b/api/calendar/serializers.py new file mode 100644 index 000000000..e57ae1b5e --- /dev/null +++ b/api/calendar/serializers.py @@ -0,0 +1,68 @@ +from rest_framework import serializers + +from db.mentor import MentorshipSession, MentorshipSessionUserLink +from db.events import Event + + +class MentorshipSessionCalendarSerializer(serializers.ModelSerializer): + mentor_name = serializers.SerializerMethodField() + mentee_count = serializers.SerializerMethodField() + + class Meta: + model = MentorshipSession + fields = [ + 'id', + 'title', + 'description', + 'mode', + 'starts_at', + 'ends_at', + 'status', + 'meeting_link', + 'venue', + 'mentor_name', + 'mentee_count', + ] + + def get_mentor_name(self, obj): + mentor_link = obj.participant_links.filter( + participant_role=MentorshipSessionUserLink.ParticipantRole.MENTOR + ).select_related('user').first() + return mentor_link.user.full_name if mentor_link else None + + def get_mentee_count(self, obj): + return obj.participant_links.filter( + participant_role=MentorshipSessionUserLink.ParticipantRole.MENTEE + ).count() + + +class EventCalendarSerializer(serializers.ModelSerializer): + ig_name = serializers.SerializerMethodField() + org_name = serializers.SerializerMethodField() + + class Meta: + model = Event + fields = [ + 'id', + 'title', + 'slug', + 'status', + 'start_datetime', + 'end_datetime', + 'venue_type', + 'venue_city', + 'venue_online_link', + 'scope', + 'cover_image', + 'organiser_type', + 'is_featured', + 'interest_count', + 'ig_name', + 'org_name', + ] + + def get_ig_name(self, obj): + return obj.scope_ig.name if obj.scope_ig else None + + def get_org_name(self, obj): + return obj.scope_org.title if obj.scope_org else None diff --git a/api/calendar/urls.py b/api/calendar/urls.py new file mode 100644 index 000000000..89240bf16 --- /dev/null +++ b/api/calendar/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import calendar_view + +urlpatterns = [ + path('ig-mentor//sessions/', calendar_view.IGMentorSessionCalendar.as_view()), + path('campus-mentor//sessions/', calendar_view.CampusMentorSessionCalendar.as_view()), + path('company//sessions/', calendar_view.CompanySessionCalendar.as_view()), + path('events/', calendar_view.EventCalendar.as_view()), + path('ig//events/', calendar_view.IGEventCalendar.as_view()), + path('campus//events/', calendar_view.CampusEventCalendar.as_view()), + path('company//events/', calendar_view.CompanyEventCalendar.as_view()), +] diff --git a/api/common/college_details_views.py b/api/common/college_details_views.py new file mode 100644 index 000000000..71325dbb0 --- /dev/null +++ b/api/common/college_details_views.py @@ -0,0 +1,72 @@ +from rest_framework.views import APIView +from django.db.models import Sum, Count, F, Q + +from utils.response import CustomResponse +from db.organization import Organization, UserOrganizationLink +from db.task import UserIgLink +from db.learning_circle import LearningCircle +from utils.types import OrganizationType +from api.dashboard.campus.serializers import CampusDetailsPublicSerializer +from rest_framework.throttling import AnonRateThrottle + +class CampusDetailsRateThrottle(AnonRateThrottle): + rate = '60/minute' + +class CollegeDetailsAPI(APIView): + throttle_classes = [CampusDetailsRateThrottle] + + def get(self, request, college_code): + # 1. Basic campus details & MuLearn details + org = Organization.objects.filter(code=college_code, org_type=OrganizationType.COLLEGE.value).first() + if org is None: + return CustomResponse(general_message="College not found").get_failure_response() + + campus_details = CampusDetailsPublicSerializer(org, many=False).data + + # 2. Top 20 learner leaderboard with karma + top_20_learners_data = [] + top_20_learners_qs = UserOrganizationLink.objects.filter( + org=org, + verified=True, + user__wallet_user__isnull=False + ).select_related('user', 'user__wallet_user').order_by('-user__wallet_user__karma')[:20] + + for link in top_20_learners_qs: + top_20_learners_data.append({ + "full_name": link.user.full_name, + "muid": link.user.muid, + "karma": link.user.wallet_user.karma, + "profile_pic": link.user.profile_pic + }) + + # 3. Campus IG details + ig_details = UserIgLink.objects.filter( + user__user_organization_link_user__org=org, + user__user_organization_link_user__verified=True + ).values( + ig_name=F('ig__name'), + ig_code=F('ig__code') + ).annotate( + members=Count('user', distinct=True), + total_karma=Sum('user__wallet_user__karma') + ).order_by('-members') + + # 4. Campus Learning Circle details + lc_details = LearningCircle.objects.filter( + org=org + ).values( + 'title', + ig_code=F('ig__code'), + ig_name=F('ig__name') + ).annotate( + members=Count('user_circle_link_circle', filter=Q(user_circle_link_circle__accepted=True), distinct=True) + ).order_by('-members') + + data = { + "campus_details": campus_details, + "top_learners": top_20_learners_data, + "ig_details": ig_details, + "lc_details": lc_details + } + + return CustomResponse(response=data).get_success_response() diff --git a/api/common/common_consumer.py b/api/common/common_consumer.py index 36516aa8a..cc9b809bb 100644 --- a/api/common/common_consumer.py +++ b/api/common/common_consumer.py @@ -12,8 +12,11 @@ from db.learning_circle import LearningCircle from db.learning_circle import UserCircleLink from db.organization import Organization -from db.task import InterestGroup, KarmaActivityLog +from db.task import InterestGroup, KarmaActivityLog, UserIgLink from db.user import User, UserRoleLink +from db.organization import UserOrganizationLink +from db.mentor import MentorshipSession +from django.core.cache import cache from utils.types import IntegrationType, OrganizationType @@ -119,7 +122,50 @@ def db_signals(sender, instance, created=None, *args, **kwargs): if created or created == None: landing_stats.get_data(sender) data = landing_stats.data - async_to_sync(channel_layer.group_send)( - "landing_stats", - {"type": "send_data", "data": data} - ) + try: + async_to_sync(channel_layer.group_send)( + "landing_stats", + {"type": "send_data", "data": data} + ) + except Exception as e: + pass + +def invalidate_scope_cache(scope_type, scope_id): + if scope_id: + try: + cache.delete(f"mentor_dash_scope:{scope_type}:{scope_id}") + except Exception: + pass + +@receiver(post_save, sender=UserOrganizationLink) +@receiver(post_delete, sender=UserOrganizationLink) +def handle_org_link_cache(sender, instance, **kwargs): + if instance.org_id: + invalidate_scope_cache("CAMPUS_MENTOR", instance.org_id) + invalidate_scope_cache("COMPANY_MENTOR", instance.org_id) + +@receiver(post_save, sender=UserIgLink) +@receiver(post_delete, sender=UserIgLink) +def handle_ig_link_cache(sender, instance, **kwargs): + if instance.ig_id: + invalidate_scope_cache("IG_MENTOR", instance.ig_id) + +@receiver(post_save, sender=MentorshipSession) +@receiver(post_delete, sender=MentorshipSession) +def handle_session_cache(sender, instance, **kwargs): + if instance.entity_id: + if instance.session_type == MentorshipSession.SessionType.CAMPUS_SESSION: + invalidate_scope_cache("CAMPUS_MENTOR", instance.entity_id) + elif instance.session_type == MentorshipSession.SessionType.COMPANY_SESSION: + invalidate_scope_cache("COMPANY_MENTOR", instance.entity_id) + elif instance.session_type == MentorshipSession.SessionType.IG_SESSION: + invalidate_scope_cache("IG_MENTOR", instance.entity_id) + +@receiver(post_save, sender=KarmaActivityLog) +@receiver(post_delete, sender=KarmaActivityLog) +def handle_karma_cache(sender, instance, **kwargs): + if instance.task: + if instance.task.org_id: + invalidate_scope_cache("COMPANY_MENTOR", instance.task.org_id) + if instance.task.ig_id: + invalidate_scope_cache("IG_MENTOR", instance.task.ig_id) diff --git a/api/common/common_views.py b/api/common/common_views.py index 983d3d50c..5bf3f6f59 100644 --- a/api/common/common_views.py +++ b/api/common/common_views.py @@ -10,7 +10,7 @@ from db.learning_circle import LearningCircle from db.learning_circle import UserCircleLink from db.organization import Organization,Department,District,State,Country -from db.task import InterestGroup, KarmaActivityLog, UserIgLink +from db.task import InterestGroup, KarmaActivityLog, Level, TaskList, UserIgLink from db.user import User, UserRoleLink from utils.response import CustomResponse from utils.types import IntegrationType, OrganizationType, RoleType @@ -18,8 +18,33 @@ from .serializer import StudentInfoSerializer, CollegeInfoSerializer, LearningCircleEnrollmentSerializer, \ UserLeaderboardSerializer,OrgSerializer,DistrictSerializer,StateSerializer,CountrySerializer, LcDetailsSerializer, \ LcListSerializer +from api.dashboard.ig.dash_ig_serializer import InterestGroupSerializer +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s class LcDetailsAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Lc Details.", + responses={200: inline_serializer("CommonLcDetailsResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": inline_serializer("CommonLcDetailsData", fields={ + "name": s.CharField(), + "circle_code": s.CharField(), + "note": s.CharField(allow_null=True), + "day": s.CharField(allow_null=True), + "college": s.CharField(allow_null=True), + "members": s.ListField(child=s.DictField()), + "rank": s.IntegerField(allow_null=True), + "total_karma": s.IntegerField(), + "ig_id": s.CharField(), + "ig_name": s.CharField(), + "ig_code": s.CharField(), + }), + })}, + ) def get(self, request, circle_id): learning_circle = LearningCircle.objects.filter(id=circle_id).first() @@ -32,6 +57,28 @@ def get(self, request, circle_id): return CustomResponse(response=serializer.data).get_success_response() class LcListAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Lc List.", + responses={200: inline_serializer("CommonLcListResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": s.ListField(child=inline_serializer("CommonLcListItem", fields={ + "id": s.CharField(), + "name": s.CharField(), + "ig_name": s.CharField(), + "org_name": s.CharField(allow_null=True), + "member_count": s.IntegerField(), + "members": s.ListField(child=s.DictField(), allow_null=True), + "meet_place": s.CharField(allow_null=True), + "meet_time": s.CharField(allow_null=True), + "lead_name": s.CharField(allow_null=True), + "karma": s.IntegerField(), + })), + "pagination": s.DictField(child=s.IntegerField()), + })}, + ) def get(self, request): all_circles = LearningCircle.objects.all() @@ -68,6 +115,26 @@ def get(self, request): class LcDashboardAPI(APIView): + @extend_schema(tags=['Common'], description="Retrieve Lc Dashboard.", + responses={200: inline_serializer("CommonLcDashboardResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": inline_serializer("CommonLcDashboardData", fields={ + "lc_count": s.IntegerField(help_text="Total number of learning circles"), + "total_enrollment": s.IntegerField(help_text="Total college-student enrollments"), + "circle_count_by_ig": s.ListField( + child=inline_serializer("CommonLcDashboardIgStat", fields={ + "name": s.CharField(), + "total_circles": s.IntegerField(), + "total_users": s.IntegerField(), + }), + help_text="Circle and user counts grouped by interest group", + ), + "unique_users": s.IntegerField(help_text="Number of unique enrolled users"), + }), + })}, + ) def get(self, request): date = request.query_params.get("date") if date: @@ -198,6 +265,11 @@ def get(self, request): class LcReportAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Lc Report.", + responses={200: StudentInfoSerializer}, + ) def get(self, request): date = request.query_params.get('date') if date: @@ -282,6 +354,11 @@ def get(self, request): class LcReportDownloadAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Lc Report Download.", + responses={200: StudentInfoSerializer}, + ) def get(self, request): student_info = ( UserCircleLink.objects.filter( @@ -320,6 +397,11 @@ def get(self, request): class CollegeWiseLcReport(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve College Wise Lc Report.", + responses={200: CollegeInfoSerializer}, + ) def get(self, request): date = request.query_params.get('date') if date: @@ -371,6 +453,11 @@ def get(self, request): class CollegeWiseLcReportCSV(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve College Wise Lc Report C S V.", + responses={200: CollegeInfoSerializer}, + ) def get(self, request): learning_circle_count_subquery = ( LearningCircle.objects.filter(org__org_type=OrganizationType.COLLEGE.value) @@ -406,6 +493,11 @@ def get(self, request): class LearningCircleEnrollment(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Learning Circle Enrollment.", + responses={200: LearningCircleEnrollmentSerializer}, + ) def get(self, request): total_no_enrollment = (UserCircleLink.objects.filter(accepted=True, user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value).values( @@ -453,6 +545,11 @@ def get(self, request): class LearningCircleEnrollmentCSV(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Learning Circle Enrollment C S V.", + responses={200: LearningCircleEnrollmentSerializer}, + ) def get(self, request): total_no_enrollment = (UserCircleLink.objects.filter(accepted=True, user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value).values( @@ -500,6 +597,32 @@ def get(self, request): class GlobalCountAPI(APIView): + @extend_schema(tags=['Common'], description="Retrieve Global Count.", + responses={200: inline_serializer("CommonGlobalCountResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": inline_serializer("CommonGlobalCountData", fields={ + "members": s.IntegerField(help_text="Total registered user count"), + "org_type_counts": s.ListField( + child=inline_serializer("CommonOrgTypeCount", fields={ + "org_type": s.CharField(), + "org_count": s.IntegerField(), + }), + help_text="Count of organisations per type (college, company, community)", + ), + "enablers_mentors_count": s.ListField( + child=inline_serializer("CommonRoleCount", fields={ + "role__title": s.CharField(), + "role_count": s.IntegerField(), + }), + help_text="Count of users with enabler / mentor roles", + ), + "ig_count": s.IntegerField(help_text="Total interest group count"), + "learning_circle_count": s.IntegerField(help_text="Total learning circle count"), + }), + })}, + ) def get(self, request): members_count = User.objects.all().count() org_type_counts = ( @@ -536,6 +659,17 @@ def get(self, request): class GTASANDSHOREAPI(APIView): + @extend_schema(tags=['Common'], description="Retrieve G T A S A N D S H O R E.", + responses={200: inline_serializer("CommonGtasandshoreResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": s.DictField( + child=s.IntegerField(), + help_text="Map of normalised college name → submission count from devfolio", + ), + })}, + ) def get(self, request): response = requests.get('https://devfolio.vez.social/rank') if response.status_code == 200: @@ -566,19 +700,155 @@ def get(self, request): class UserProfilePicAPI(APIView): + @extend_schema(tags=['Common'], description="Retrieve User Profile Pic.", + responses={200: inline_serializer("CommonUserProfilePicResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": inline_serializer("CommonUserProfilePicData", fields={ + "image": s.CharField(allow_null=True, help_text="URL or path to the user's profile picture"), + }), + })}, + ) def get(self, request, muid): - user = User.objects.filter(muid=muid).annotate(image=F("profile_pic")).values("image") - return CustomResponse(response=user).get_success_response() + user = User.objects.filter(muid=muid).first() + if user is None: + return CustomResponse(general_message='User not found').get_failure_response() + + return CustomResponse(response={'image': user.profile_pic}).get_success_response() class ListIGAPI(APIView): + @extend_schema(tags=['Common'], description="Retrieve List I G.", + responses={200: inline_serializer("CommonListIGResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": s.ListField( + child=inline_serializer("CommonListIGItem", fields={ + "name": s.CharField(help_text="Interest group name"), + }), + help_text="All interest groups", + ), + })}, + ) def get(self, request): return CustomResponse(response=InterestGroup.objects.all().values("name")).get_success_response() +class IGDetailAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve I G Detail.", + responses={200: InterestGroupSerializer}, + ) + def get(self, request, pk): + from api.dashboard.ig.dash_ig_serializer import InterestGroupSerializer + + ig_data = ( + InterestGroup.objects.prefetch_related("user_ig_link_ig") + .filter(id=pk) + .first() + ) + + if not ig_data: + return CustomResponse( + general_message="Interest Group Not Found" + ).get_failure_response() + + serializer = InterestGroupSerializer(ig_data, many=False) + return CustomResponse( + response={"interestGroup": serializer.data} + ).get_success_response() +class ListAllLevelInfo(APIView): + @extend_schema(tags=['Common'], description="Retrieve List All Level Info.", + responses={200: inline_serializer("CommonListAllLevelInfoResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": s.ListField( + child=inline_serializer("CommonLevelItem", fields={ + "name": s.CharField(help_text="Level name"), + "tasks": s.ListField( + child=inline_serializer("CommonLevelTask", fields={ + "task_name": s.CharField(), + "discord_link": s.CharField(allow_null=True), + "hashtag": s.CharField(allow_null=True), + "active": s.BooleanField(), + "completed": s.BooleanField(), + "karma": s.IntegerField(), + "task_description": s.CharField(allow_null=True), + "interest_group": inline_serializer("CommonLevelTaskIG", fields={ + "id": s.CharField(allow_null=True), + "name": s.CharField(allow_null=True), + }), + "submission_channel": inline_serializer("CommonLevelTaskChannel", fields={ + "id": s.CharField(allow_null=True), + "name": s.CharField(allow_null=True), + "discord_id": s.CharField(allow_null=True), + }), + }), + ), + }), + help_text="Levels with their associated tasks", + ), + })}, + ) + def get(self, request): + + levels = Level.objects.all().order_by("level_order") + response = [] + for level in levels: + tasks = TaskList.objects.filter(level=level, active=True).select_related("ig", "channel") + task_list = [] + for task in tasks: + task_list.append({ + "task_name": task.title, + "discord_link": getattr(task, "discord_link", ""), + "hashtag": getattr(task, "hashtag", ""), + "active": getattr(task, "active", False), + "completed": False, # You can update this logic as needed + "karma": getattr(task, "karma", 0), + "task_description": getattr(task, "description", None), + "interest_group": { + "id": getattr(task.ig, "id", None) if task.ig else None, + "name": getattr(task.ig, "name", None) if task.ig else None + }, + "submission_channel": { + "id": str(getattr(task.channel, "id", "")) if task.channel else None, + "name": getattr(task.channel, "name", None) if task.channel else None, + "discord_id": getattr(task.channel, "discord_id", None) if task.channel else None + } + }) + response.append({ + "name": level.name, + "tasks": task_list + }) + return CustomResponse(response=response).get_success_response() + class ListTopIgUsersAPI(APIView): + @extend_schema(tags=['Common'], description="Retrieve List Top Ig Users.", + responses={200: inline_serializer("CommonListTopIgUsersResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(child=s.CharField(), default={}), + "response": s.ListField( + child=inline_serializer("CommonTopIgUserItem", fields={ + "userid": s.CharField(help_text="User UUID"), + "muid": s.CharField(help_text="User unique identifier"), + "full_name": s.CharField(), + "ig_karma": s.IntegerField(help_text="Total karma earned in the requested interest groups"), + "igs": s.ListField( + child=s.CharField(), + help_text="All interest group names the user belongs to", + ), + }), + help_text="Top 100 users ranked by karma in the requested interest groups", + ), + })}, + ) def get(self, request): ig_name = request.query_params.getlist("ig_name", []) @@ -622,6 +892,11 @@ def get(self, request): class BekenAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Beken.", + responses={200: UserLeaderboardSerializer}, + ) def get(self, request): user_info = User.objects.exclude( user_role_link_user__role__title__in=[RoleType.ENABLER.value, RoleType.MENTOR.value]).order_by( @@ -631,6 +906,11 @@ def get(self, request): class LcCollegeAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Lc College.", + responses={200: OrgSerializer}, + ) def get(self, request): org_queryset = Organization.objects.filter( Q(org_type=OrganizationType.COLLEGE.value), @@ -656,6 +936,11 @@ def get(self, request): class LcDistrictAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Lc District.", + responses={200: DistrictSerializer}, + ) def get(self, request): district = District.objects.filter(zone__state_id=request.query_params.get("state_id")) @@ -668,6 +953,11 @@ def get(self, request): ).get_success_response() class LcStateAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Lc State.", + responses={200: StateSerializer}, + ) def get(self, request): state = State.objects.filter(country_id=request.query_params.get("country_id")) @@ -681,6 +971,11 @@ def get(self, request): class LcCountryAPI(APIView): + @extend_schema( + tags=['Common'], + description="Retrieve Lc Country.", + responses={200: CountrySerializer}, + ) def get(self, request): countries = Country.objects.all() diff --git a/api/common/external_api_serializer.py b/api/common/external_api_serializer.py new file mode 100644 index 000000000..2a7832138 --- /dev/null +++ b/api/common/external_api_serializer.py @@ -0,0 +1,65 @@ +from rest_framework import serializers + +from db.user import User + + +class ExternalUserDetailsSerializer(serializers.ModelSerializer): + """ + Serializer for external API to fetch basic public user details. + + This serializer is specifically designed for external website integration, + returning only non-sensitive public information about users. + + Excludes: email, mobile, password, and other sensitive fields + """ + interest_groups = serializers.SerializerMethodField() + organizations = serializers.SerializerMethodField() + karma = serializers.IntegerField(source="wallet_user.karma", default=0) + + class Meta: + model = User + fields = ( + "full_name", + "muid", + "interest_groups", + "organizations", + "profile_pic", + "karma", + ) + + def get_organizations(self, obj): + """ + Get list of organizations the user is affiliated with. + + Returns organization id, title, code, and type. + """ + org_links = ( + obj.user_organization_link_user.all() + .select_related("org") + .only("org__id", "org__title", "org__code", "org__org_type") + ) + data = [] + for org_link in org_links: + data.append( + { + "id": org_link.org.id, + "title": org_link.org.title, + "code": org_link.org.code, + "org_type": org_link.org.org_type, + } + ) + return data + + def get_interest_groups(self, obj): + """ + Get list of interest groups the user belongs to. + + Returns interest group id and name. + """ + ig_links = ( + obj.user_ig_link_user.all().select_related("ig").only("ig__id", "ig__name") + ) + data = [] + for ig_link in ig_links: + data.append({"id": ig_link.ig.id, "name": ig_link.ig.name}) + return data diff --git a/api/common/external_api_views.py b/api/common/external_api_views.py new file mode 100644 index 000000000..7a13395d3 --- /dev/null +++ b/api/common/external_api_views.py @@ -0,0 +1,111 @@ +from functools import wraps +from django.core.cache import cache +from rest_framework.views import APIView + +from db.user import User +from utils.response import CustomResponse +from .external_api_serializer import ExternalUserDetailsSerializer +from drf_spectacular.utils import extend_schema + + +def rate_limit(max_requests=100, time_window=60): + """ + Rate limiting decorator for API views. + + Args: + max_requests: Maximum number of requests allowed (default: 100) + time_window: Time window in seconds (default: 60 seconds) + + Returns 429 if rate limit is exceeded. + Uses Redis cache to track request counts per IP address. + """ + def decorator(view_func): + @wraps(view_func) + def wrapped_view(self, request, *args, **kwargs): + ip_address = request.META.get('HTTP_X_FORWARDED_FOR') + if ip_address: + ip_address = ip_address.split(',')[0].strip() + else: + ip_address = request.META.get('REMOTE_ADDR') + + cache_key = f"rate_limit_external_api_{ip_address}" + request_count = cache.get(cache_key, 0) + + if request_count >= max_requests: + return CustomResponse( + general_message=f"Rate limit exceeded. Maximum {max_requests} requests per minute allowed." + ).get_failure_response(http_status_code=429) + if request_count == 0: + cache.set(cache_key, 1, time_window) + else: + cache.incr(cache_key) + return view_func(self, request, *args, **kwargs) + + return wrapped_view + return decorator + + +class ExternalUserDetailsAPI(APIView): + """ + External API endpoint for fetching basic user details. + + This endpoint is designed for external website integration. + Public endpoint (no authentication required) that only returns public user profiles. + + Rate limited to 100 requests per minute per IP address. + """ + + @rate_limit(max_requests=100, time_window=60) + @extend_schema( + tags=['Common'], + description="Retrieve External User Details.", + responses={200: ExternalUserDetailsSerializer}, + ) + def get(self, request): + """ + Fetch basic user details by muid. + + Query Parameters: + muid (str): The unique mulearn user ID (format: username@mulearn) + + Returns: + 200: User details (if public profile) + 400: Missing muid parameter + 403: Private profile + 404: User not found + 429: Rate limit exceeded + """ + muid = request.query_params.get("muid") + + if not muid: + return CustomResponse( + general_message="muid parameter is required" + ).get_failure_response() + + try: + user = ( + User.objects + .select_related("wallet_user", "user_settings_user") + .prefetch_related( + "user_organization_link_user__org", + "user_ig_link_user__ig" + ) + .get(muid=muid) + ) + except User.DoesNotExist: + return CustomResponse( + general_message="User not found" + ).get_failure_response(http_status_code=404) + + try: + is_public = user.user_settings_user.is_public + except: + is_public = False + + if not is_public: + return CustomResponse( + general_message="This user profile is private" + ).get_failure_response(http_status_code=403) + + serializer = ExternalUserDetailsSerializer(user) + return CustomResponse(response=serializer.data).get_success_response() diff --git a/api/common/urls.py b/api/common/urls.py index 8a9fb3929..d1f5ab26c 100644 --- a/api/common/urls.py +++ b/api/common/urls.py @@ -1,8 +1,13 @@ from django.urls import path +from .common_views import * +from .external_api_views import ExternalUserDetailsAPI from . import common_views +from .college_details_views import CollegeDetailsAPI +from api.dashboard.company import job_views urlpatterns = [ + path('campus-details//', CollegeDetailsAPI.as_view()), path('lc-list', common_views.LcListAPI.as_view()), path('/lc-details/', common_views.LcDetailsAPI.as_view()), path('lc-dashboard/', common_views.LcDashboardAPI.as_view()), @@ -19,6 +24,7 @@ path('profile-pic//', common_views.UserProfilePicAPI.as_view()), path('list-ig/', common_views.ListIGAPI.as_view()), path('list-ig-top100/', common_views.ListTopIgUsersAPI.as_view()), + path("list/levels/", common_views.ListAllLevelInfo.as_view()), path('leaderboard/top-100/', common_views.BekenAPI.as_view()), @@ -27,4 +33,7 @@ path("list/district/", common_views.LcDistrictAPI.as_view()), path("list/state/", common_views.LcStateAPI.as_view()), path("list/country/", common_views.LcCountryAPI.as_view()), + path("external/user/", ExternalUserDetailsAPI.as_view()), + path('jobs/', job_views.PublicJobAPI.as_view(), name='public-jobs-list'), + path('ig//', common_views.IGDetailAPI.as_view()), ] diff --git a/api/dashboard/achievement/achievement_events.py b/api/dashboard/achievement/achievement_events.py new file mode 100644 index 000000000..a40bd5b5b --- /dev/null +++ b/api/dashboard/achievement/achievement_events.py @@ -0,0 +1,239 @@ +""" +Achievement Event Producer + +Emits normalized events for the achievement system. +All events are immutable and append-only. + +Usage: + from api.dashboard.achievement.achievement_events import emit_task_completed + emit_task_completed(user_id="...", task_id="...", ig_id="...", karma=50) +""" +import uuid +from datetime import datetime, date +from django.db import transaction +import logging + +logger = logging.getLogger(__name__) + + +class EventType: + """Canonical event types for the achievement system""" + TASK_COMPLETED = "task.completed" + KARMA_AWARDED = "karma.awarded" + USER_LOGIN = "user.login" + DISCORD_ACTIVITY = "discord.activity" + EVENT_ATTENDED = "event.attended" + LC_MEETING_ATTENDED = "lc.meeting.attended" + STREAK_MILESTONE = "streak.milestone" + + +class AchievementEventProducer: + """ + Produces achievement events with exactly-once semantics. + Events are persisted to DB then queued for async processing. + """ + + @staticmethod + def emit( + event_type: str, + user_id: str, + metadata: dict, + region: str = None, + event_id: str = None, + ) -> str: + """ + Emit an achievement event. + + Args: + event_type: Type of event (use EventType constants) + user_id: User who triggered the event + metadata: Event-specific data (task_id, ig_id, karma, etc.) + region: Optional region for partitioning + event_id: Optional idempotency key (auto-generated if not provided) + + Returns: + event_id for tracking + """ + from db.achievement import AchievementEvent + from mu_celery.achievement_tasks import process_achievement_event + + if event_id is None: + event_id = str(uuid.uuid4()) + + try: + with transaction.atomic(): + event = AchievementEvent.objects.create( + id=str(uuid.uuid4()), + event_id=event_id, + event_type=event_type, + user_id=user_id, + region=region, + metadata=metadata, + processed=False, + created_at=datetime.now(), + ) + + # Queue for async processing + process_achievement_event.delay(str(event.id)) + + logger.info( + f"Achievement event emitted: {event_type} for user {user_id}" + ) + return event_id + + except Exception as e: + # If duplicate event_id, silently ignore (idempotent) + if "Duplicate entry" in str(e) or "uk_event_id" in str(e): + logger.debug(f"Duplicate event ignored: {event_id}") + return event_id + logger.exception(f"Error emitting event: {e}") + raise + + +# ============================================================================ +# Convenience functions for common events +# ============================================================================ + + +def emit_task_completed( + user_id: str, + task_id: str, + ig_id: str = None, + karma: int = 0, + skill_ids: list = None, +) -> str: + """ + Emit event when a task is completed. + + Args: + user_id: User who completed the task + task_id: ID of the completed task + ig_id: Interest Group ID (if task belongs to an IG) + karma: Karma points awarded + skill_ids: List of skill IDs related to this task + """ + return AchievementEventProducer.emit( + event_type=EventType.TASK_COMPLETED, + user_id=user_id, + metadata={ + "task_id": task_id, + "ig_id": ig_id, + "karma": karma, + "skill_ids": skill_ids or [], + }, + event_id=f"task_{task_id}_{user_id}", # Idempotency key + ) + + +def emit_karma_awarded( + user_id: str, + karma: int, + source: str, + ig_id: str = None, + task_id: str = None, +) -> str: + """ + Emit event when karma is awarded. + + Args: + user_id: User receiving karma + karma: Amount of karma awarded + source: Source of karma (task, referral, bonus, etc.) + ig_id: Interest Group ID if applicable + task_id: Task ID if karma is from a task + """ + return AchievementEventProducer.emit( + event_type=EventType.KARMA_AWARDED, + user_id=user_id, + metadata={ + "karma": karma, + "source": source, + "ig_id": ig_id, + "task_id": task_id, + }, + ) + + +def emit_user_login(user_id: str) -> str: + """ + Emit event when user logs in (once per day for streak tracking). + + Args: + user_id: User who logged in + """ + today = date.today() + return AchievementEventProducer.emit( + event_type=EventType.USER_LOGIN, + user_id=user_id, + metadata={"date": str(today)}, + event_id=f"login_{user_id}_{today}", # Once per day + ) + + +def emit_event_attended( + user_id: str, + event_name: str, + event_id: str = None, +) -> str: + """ + Emit event when user attends an event. + + Args: + user_id: User who attended + event_name: Name of the event + event_id: Event ID for deduplication + """ + return AchievementEventProducer.emit( + event_type=EventType.EVENT_ATTENDED, + user_id=user_id, + metadata={"event_name": event_name}, + event_id=f"event_{event_id}_{user_id}" if event_id else None, + ) + + +def emit_lc_meeting_attended( + user_id: str, + circle_id: str, + meeting_id: str, +) -> str: + """ + Emit event when user attends a Learning Circle meeting. + + Args: + user_id: User who attended + circle_id: Learning Circle ID + meeting_id: Meeting ID + """ + return AchievementEventProducer.emit( + event_type=EventType.LC_MEETING_ATTENDED, + user_id=user_id, + metadata={ + "circle_id": circle_id, + "meeting_id": meeting_id, + }, + event_id=f"lc_{meeting_id}_{user_id}", # Idempotency + ) + + +def emit_streak_milestone( + user_id: str, + streak_type: str, + streak_count: int, +) -> str: + """ + Emit event when user reaches a streak milestone. + + Args: + user_id: User who reached the milestone + streak_type: Type of streak (daily_task, daily_login) + streak_count: Number of days in streak + """ + return AchievementEventProducer.emit( + event_type=EventType.STREAK_MILESTONE, + user_id=user_id, + metadata={ + "streak_type": streak_type, + "streak_count": streak_count, + }, + event_id=f"streak_{user_id}_{streak_type}_{streak_count}", + ) diff --git a/api/dashboard/achievement/achievement_serializer.py b/api/dashboard/achievement/achievement_serializer.py index 82e6647c3..663204e84 100644 --- a/api/dashboard/achievement/achievement_serializer.py +++ b/api/dashboard/achievement/achievement_serializer.py @@ -1,19 +1,51 @@ from rest_framework import serializers +from django.conf import settings from db.achievement import Achievement, UserAchievementsLog # from db.user import User class AchievementSerializer(serializers.ModelSerializer): + icon_url = serializers.SerializerMethodField() + has_achievement = serializers.SerializerMethodField() class Meta: model = Achievement fields = '__all__' + def get_icon_url(self, obj): + if obj.icon: + # Check if it's already a full URL + if obj.icon.startswith('http://') or obj.icon.startswith('https://'): + return obj.icon + # Build full URL with domain + request = self.context.get('request') + if request: + return request.build_absolute_uri(f"{settings.MEDIA_URL}{obj.icon}") + # Fallback: return media path (frontend will need to prepend backend URL) + return f"{settings.MEDIA_URL}{obj.icon}" + return None + + def get_has_achievement(self, obj): + """ + Returns True if the user (passed in context as 'user_achievements') + already holds this achievement, otherwise False. + """ + user_achievements = self.context.get('user_achievements', set()) + return obj.id in user_achievements + class AchievementBasicSerializer(serializers.ModelSerializer): achievement_name = serializers.CharField(source='name') + icon_url = serializers.SerializerMethodField() class Meta: model = Achievement - fields = ['id', 'achievement_name', 'description', 'icon', 'level_id', 'tags', 'template_id'] + fields = ['id', 'achievement_name', 'description', 'icon', 'icon_url', 'level_id', 'tags', 'template_id'] + + def get_icon_url(self, obj): + if obj.icon: + if obj.icon.startswith('http://') or obj.icon.startswith('https://'): + return obj.icon + return f"{settings.MEDIA_URL}{obj.icon}" + return None class UserAchievementsSerializer(serializers.ModelSerializer): achievement = AchievementBasicSerializer(source='achievement_id', read_only=True) diff --git a/api/dashboard/achievement/achievement_views.py b/api/dashboard/achievement/achievement_views.py index 83453d0fa..ca91ce03a 100644 --- a/api/dashboard/achievement/achievement_views.py +++ b/api/dashboard/achievement/achievement_views.py @@ -1,17 +1,38 @@ +import json +import os +import uuid +from io import BytesIO + +import openpyxl +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.http import FileResponse +from django.utils.timezone import now from rest_framework.generics import get_object_or_404 +from rest_framework.parsers import FormParser, MultiPartParser, JSONParser from rest_framework.views import APIView -from . import achievement_serializer -from db.achievement import Achievement, UserAchievementsLog -from utils.response import CustomResponse -from utils.permission import JWTUtils -from db.user import User + +from datetime import datetime, timedelta +from api.dashboard.achievement import achievement_serializer +from db.achievement import Achievement, AchievementRule, UserAchievementsLog, AchievementAuditLog from db.task import Level -import uuid -from django.utils.timezone import now -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from db.user import User +from mu_celery.achievement_tasks import bulk_check_and_issue_achievements +from utils.permission import CustomizePermission, JWTUtils, RoleRequired, BackendApiKeyPermission +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import DateTimeUtils, CommonUtils +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s class AchievementListAPIView(APIView): + @extend_schema( + tags=['Dashboard - Achievement'], + description="Retrieve Achievement List. Pass ?user_id= to include a 'has_achievement' flag per item.", + responses={200: achievement_serializer.AchievementSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -27,9 +48,24 @@ def get(self, request): general_message="User Not Exists" ).get_failure_response() + # Optional: check which achievements a specific user already has + target_user_id = request.query_params.get('user_id') + user_achievement_ids = set() + if target_user_id: + user_achievement_ids = set( + UserAchievementsLog.objects.filter( + user_id=target_user_id + ).values_list('achievement_id_id', flat=True) + ) + achievements = Achievement.objects.all() achievements_serializer = achievement_serializer.AchievementSerializer( - achievements, many=True + achievements, + many=True, + context={ + 'request': request, + 'user_achievements': user_achievement_ids, + } ) return CustomResponse( @@ -38,6 +74,11 @@ def get(self, request): class AchievementCreateAPIView(APIView): + parser_classes = [MultiPartParser, FormParser, JSONParser] + + @extend_schema(tags=['Dashboard - Achievement'], description="Create Achievement Create.", + responses={200: achievement_serializer.AchievementSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -53,14 +94,29 @@ def post(self, request): ).get_failure_response() data = request.data - required_fields = ["name", "description", "icon", "tags", "type", "has_vc"] - + # Icon can be either a file upload or a text URL + icon_file = request.FILES.get("icon") + icon_url = data.get("icon", "") if not icon_file else "" + + required_fields = ["name", "description", "tags", "type", "has_vc"] missing_fields = [field for field in required_fields if field not in data] if missing_fields: return CustomResponse( general_message=f"Missing required fields: {', '.join(missing_fields)}" ).get_failure_response() + # Parse has_vc from string to boolean (FormData sends strings) + has_vc_value = data.get("has_vc") + if isinstance(has_vc_value, str): + has_vc_value = has_vc_value.lower() in ("true", "1", "yes") + + tags_value = data.get("tags", []) + if isinstance(tags_value, str): + try: + tags_value = json.loads(tags_value) + except json.JSONDecodeError: + tags_value = [] + if Achievement.objects.filter(name=data["name"]).exists(): return CustomResponse( general_message="Name already exists" @@ -75,15 +131,48 @@ def post(self, request): general_message="Invalid level_id" ).get_failure_response() + # Handle icon file upload + icon_path = icon_url # Default to URL if provided + if icon_file: + # Validate file type + allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] + file_ext = icon_file.name.split('.')[-1].lower() + if file_ext not in allowed_extensions: + return CustomResponse( + general_message=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}" + ).get_failure_response() + + # Validate file size (max 5MB) + if icon_file.size > 5 * 1024 * 1024: + return CustomResponse( + general_message="File size exceeds 5MB limit" + ).get_failure_response() + + # Create directory if it doesn't exist + upload_dir = os.path.join(settings.MEDIA_ROOT, 'achievements', 'icons') + os.makedirs(upload_dir, exist_ok=True) + + # Generate unique filename + unique_filename = f"{uuid.uuid4()}.{file_ext}" + file_path = os.path.join(upload_dir, unique_filename) + + # Save file + with open(file_path, 'wb+') as destination: + for chunk in icon_file.chunks(): + destination.write(chunk) + + # Store relative path for database + icon_path = f"achievements/icons/{unique_filename}" + achievement = Achievement.objects.create( id=str(uuid.uuid4()), name=data["name"], description=data["description"], - icon=data["icon"], - tags=data["tags"], + icon=icon_path, + tags=tags_value, type=data["type"], level_id=level, - has_vc=data["has_vc"], + has_vc=has_vc_value, template_id=data.get("template_id"), created_by=user, updated_by=user, @@ -97,6 +186,13 @@ def post(self, request): class AchievementUpdateAPIView(APIView): + parser_classes = [MultiPartParser, FormParser, JSONParser] + + @extend_schema( + tags=['Dashboard - Achievement'], + description="Update Achievement Update.", + responses={200: achievement_serializer.AchievementSerializer}, + ) def put(self, request, achievement_id=None): user_id = JWTUtils.fetch_user_id(request) @@ -123,9 +219,52 @@ def put(self, request, achievement_id=None): general_message="Achievement not found" ).get_failure_response() - data = request.data.copy() + # Convert QueryDict to regular dict to properly handle list/boolean assignments + data = dict(request.data) + # QueryDict wraps values in lists, so unwrap single values + # Exclude intentional list fields like 'tags' from unwrapping + list_fields = {'tags'} + for key in data: + if key not in list_fields and isinstance(data[key], list) and len(data[key]) == 1: + data[key] = data[key][0] data["updated_by"] = user_id + # Handle icon file upload + icon_file = request.FILES.get("icon") + if icon_file: + # Validate file type + allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] + file_ext = icon_file.name.split('.')[-1].lower() + if file_ext not in allowed_extensions: + return CustomResponse( + general_message=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}" + ).get_failure_response() + + # Validate file size (max 5MB) + if icon_file.size > 5 * 1024 * 1024: + return CustomResponse( + general_message="File size exceeds 5MB limit" + ).get_failure_response() + + # Create directory if it doesn't exist + upload_dir = os.path.join(settings.MEDIA_ROOT, 'achievements', 'icons') + os.makedirs(upload_dir, exist_ok=True) + + # Generate unique filename + unique_filename = f"{uuid.uuid4()}.{file_ext}" + file_path = os.path.join(upload_dir, unique_filename) + + # Save file + with open(file_path, 'wb+') as destination: + for chunk in icon_file.chunks(): + destination.write(chunk) + + # Store relative path for database + data["icon"] = f"achievements/icons/{unique_filename}" + elif "icon" not in data or not data["icon"]: + # Keep existing icon if no new file or URL provided + data["icon"] = achievement.icon + if "level_id" in data: if data["level_id"]: try: @@ -138,6 +277,21 @@ def put(self, request, achievement_id=None): else: data["level_id"] = None + # Parse has_vc from string to boolean (FormData sends strings) + if "has_vc" in data: + has_vc_value = data.get("has_vc") + if isinstance(has_vc_value, str): + data["has_vc"] = has_vc_value.lower() in ("true", "1", "yes") + + # Parse tags from JSON string to list (FormData sends strings) + if "tags" in data: + tags_value = data.get("tags", []) + if isinstance(tags_value, str): + try: + data["tags"] = json.loads(tags_value) + except json.JSONDecodeError: + data["tags"] = [] + serializer = achievement_serializer.AchievementSerializer( achievement, data=data, partial=True ) @@ -154,6 +308,9 @@ def put(self, request, achievement_id=None): class AchievementDeleteAPIView(APIView): + @extend_schema(tags=['Dashboard - Achievement'], description="Delete Achievement Delete.", + responses={200: achievement_serializer.AchievementSerializer}, + ) def delete(self, request, achievement_id): user_id = JWTUtils.fetch_user_id(request) @@ -162,8 +319,7 @@ def delete(self, request, achievement_id): general_message="Invalid or missing token" ).get_failure_response() - user = User.objects.filter(id=user_id).first() - if not user: + if not User.objects.filter(id=user_id).exists(): return CustomResponse( general_message="User Not Exists" ).get_failure_response() @@ -182,6 +338,11 @@ def delete(self, request, achievement_id): class UserAchievementsListAPIView(APIView): + @extend_schema( + tags=['Dashboard - Achievement'], + description="Retrieve User Achievements List.", + responses={200: achievement_serializer.UserAchievementsSerializer}, + ) def get(self, request, muid): try: user = get_object_or_404(User, muid=muid) @@ -222,6 +383,9 @@ def get(self, request, muid): class UserAchievementsIssueAPIView(APIView): + @extend_schema(tags=['Dashboard - Achievement'], description="Create User Achievements Issue.", + responses={200: achievement_serializer.AchievementSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) if not user_id: @@ -256,7 +420,7 @@ def post(self, request): general_message="Achievement record not found" ).get_failure_response() - if user_achievement.is_issued: + if user_achievement.vc_url: return CustomResponse( general_message="This achievement has already been issued" ).get_failure_response() @@ -268,3 +432,822 @@ def post(self, request): return CustomResponse( general_message="Achievement issued successfully" ).get_success_response() + + +# ============================================================================ +# NEW ACHIEVEMENT SYSTEM VIEWS +# ============================================================================ + + +class EligibleAchievementsAPIView(APIView): + """Get achievements the current user is eligible to claim""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Retrieve Eligible Achievements.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + from api.dashboard.achievement.rule_engine import RuleEvaluator + + evaluator = RuleEvaluator(user_id) + eligible = evaluator.get_eligible_achievements() + + response_data = [ + { + "achievement_id": result.achievement_id, + "achievement_name": result.achievement_name, + "eligible": result.eligible, + "reason": result.reason, + "progress": result.progress, + } + for result in eligible + ] + + return CustomResponse(response=response_data).get_success_response() + + +class ClaimAchievementAPIView(APIView): + """Claim an achievement (user action)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Create Claim Achievement.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def post(self, request, achievement_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + from mu_celery.achievement_tasks import claim_achievement + + result = claim_achievement(user_id, achievement_id) + + if result["success"]: + return CustomResponse( + general_message=result["message"], + response={ + "achievement_name": result.get("achievement_name"), + "vc_pending": result.get("vc_pending", False), + }, + ).get_success_response() + else: + return CustomResponse( + general_message=result["message"], + response={"progress": result.get("progress")}, + ).get_failure_response() + + +class UserProgressAPIView(APIView): + """Get progress towards all achievements""" + + @extend_schema( + tags=['Dashboard - Achievement'], description="Retrieve User Progress.", + responses={200: inline_serializer("AchievementUserProgressResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.ListField( + child=inline_serializer("AchievementProgressItem", fields={ + "achievement_id": s.CharField(), + "achievement_name": s.CharField(), + "eligible": s.BooleanField(), + "claimed": s.BooleanField(), + "reason": s.CharField(allow_null=True), + "progress": s.DictField( + help_text="Rule-engine progress data (e.g. current vs. required counts)" + ), + }), + help_text="Progress towards every achievement for the current user", + ), + })}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + from api.dashboard.achievement.rule_engine import RuleEvaluator + + evaluator = RuleEvaluator(user_id) + all_progress = evaluator.get_all_progress() + + response_data = [ + { + "achievement_id": result.achievement_id, + "achievement_name": result.achievement_name, + "eligible": result.eligible, + "claimed": result.claimed, + "reason": result.reason, + "progress": result.progress, + } + for result in all_progress + ] + + return CustomResponse(response=response_data).get_success_response() + + +class AchievementRuleListAPIView(APIView): + """List all achievement rules (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Retrieve Achievement Rule List.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + rules = AchievementRule.objects.all().select_related("achievement") + data = [ + { + "id": str(rule.id), + "achievement_id": str(rule.achievement_id), + "achievement_name": rule.achievement.name, + "version": rule.version, + "rule_type": rule.rule_type, + "conditions": rule.conditions, + "is_active": rule.is_active, + "created_at": rule.created_at.isoformat() if rule.created_at else None, + } + for rule in rules + ] + + return CustomResponse(response=data).get_success_response() + + +class AchievementRuleCreateAPIView(APIView): + """Create a new achievement rule (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Create Achievement Rule Create.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + data = request.data + required_fields = ["achievement_id", "rule_type", "conditions"] + missing_fields = [field for field in required_fields if field not in data] + if missing_fields: + return CustomResponse( + general_message=f"Missing required fields: {', '.join(missing_fields)}" + ).get_failure_response() + + # Get next version number + existing_versions = AchievementRule.objects.filter( + achievement_id=data["achievement_id"] + ).values_list("version", flat=True) + next_version = max(existing_versions) + 1 if existing_versions else 1 + + # Deactivate existing rules for this achievement + AchievementRule.objects.filter( + achievement_id=data["achievement_id"], is_active=True + ).update(is_active=False) + + rule = AchievementRule.objects.create( + id=str(uuid.uuid4()), + achievement_id=data["achievement_id"], + version=next_version, + rule_type=data["rule_type"], + conditions=data["conditions"], + is_active=True, + created_by_id=user_id, + created_at=now(), + updated_at=now(), + ) + + return CustomResponse( + general_message=f"Rule v{next_version} created successfully", + response={"rule_id": str(rule.id), "version": next_version}, + ).get_success_response() + + +class AchievementRuleDetailAPIView(APIView): + """Get or update details of a specific rule (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Retrieve Achievement Rule Detail.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def get(self, request, rule_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + try: + rule = AchievementRule.objects.select_related("achievement").get(id=rule_id) + except AchievementRule.DoesNotExist: + return CustomResponse( + general_message="Rule not found" + ).get_failure_response() + + data = { + "id": str(rule.id), + "achievement_id": str(rule.achievement_id), + "achievement_name": rule.achievement.name, + "version": rule.version, + "rule_type": rule.rule_type, + "conditions": rule.conditions, + "is_active": rule.is_active, + "created_at": rule.created_at.isoformat() if rule.created_at else None, + } + + return CustomResponse(response=data).get_success_response() + + @extend_schema( + tags=['Dashboard - Achievement'], + description="Update a rule's rule_type and/or conditions. Works for both active and deactivated rules. Version and achievement association are immutable.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + @RoleRequired([RoleType.ADMIN.value]) + def patch(self, request, rule_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + try: + rule = AchievementRule.objects.select_related("achievement").get(id=rule_id) + except AchievementRule.DoesNotExist: + return CustomResponse( + general_message="Rule not found" + ).get_failure_response() + + EDITABLE_FIELDS = {"rule_type", "conditions"} + data = request.data + + unknown_fields = set(data.keys()) - EDITABLE_FIELDS + if unknown_fields: + return CustomResponse( + general_message=f"Fields not editable: {', '.join(sorted(unknown_fields))}. Only rule_type and conditions can be updated." + ).get_failure_response() + + if not EDITABLE_FIELDS.intersection(data.keys()): + return CustomResponse( + general_message="No editable fields provided. Supply at least one of: rule_type, conditions." + ).get_failure_response() + + if "rule_type" in data: + valid_rule_types = [choice[0] for choice in AchievementRule.RULE_TYPE_CHOICES] + if data["rule_type"] not in valid_rule_types: + return CustomResponse( + general_message=f"Invalid rule_type '{data['rule_type']}'. Valid choices: {', '.join(valid_rule_types)}" + ).get_failure_response() + rule.rule_type = data["rule_type"] + + if "conditions" in data: + if not isinstance(data["conditions"], dict): + return CustomResponse( + general_message="conditions must be a JSON object." + ).get_failure_response() + rule.conditions = data["conditions"] + + rule.save(update_fields=[f for f in EDITABLE_FIELDS if f in data] + ["updated_at"]) + + return CustomResponse( + general_message=f"Rule v{rule.version} updated successfully", + response={ + "id": str(rule.id), + "achievement_id": str(rule.achievement_id), + "achievement_name": rule.achievement.name, + "version": rule.version, + "rule_type": rule.rule_type, + "conditions": rule.conditions, + "is_active": rule.is_active, + }, + ).get_success_response() + + +class AchievementRuleDeactivateAPIView(APIView): + """Deactivate a rule (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Create Achievement Rule Deactivate.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def post(self, request, rule_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + try: + rule = AchievementRule.objects.get(id=rule_id) + except AchievementRule.DoesNotExist: + return CustomResponse( + general_message="Rule not found" + ).get_failure_response() + + rule.is_active = False + rule.save() + + return CustomResponse( + general_message=f"Rule v{rule.version} deactivated" + ).get_success_response() + + +class AchievementRuleActivateAPIView(APIView): + """Activate a rule (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Activate an Achievement Rule.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def post(self, request, rule_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + try: + rule = AchievementRule.objects.get(id=rule_id) + except AchievementRule.DoesNotExist: + return CustomResponse( + general_message="Rule not found" + ).get_failure_response() + + if rule.is_active: + return CustomResponse( + general_message=f"Rule v{rule.version} is already active" + ).get_failure_response() + + rule.is_active = True + rule.save() + + return CustomResponse( + general_message=f"Rule v{rule.version} activated" + ).get_success_response() + + +class SimulateRulesAPIView(APIView): + """Simulate rule evaluation for a user (admin/debug)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Retrieve Simulate Rules.", + responses={200: inline_serializer("AchievementSimulateRulesResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.ListField( + child=inline_serializer("AchievementSimulateItem", fields={ + "achievement_id": s.CharField(), + "achievement_name": s.CharField(), + "eligible": s.BooleanField(), + "claimed": s.BooleanField(), + "reason": s.CharField(allow_null=True), + "progress": s.DictField( + help_text="Rule-engine progress data for the target user" + ), + }), + help_text="Simulated rule evaluation results for the given user (by muid)", + ), + })}, + ) + def get(self, request, muid): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + try: + target_user = User.objects.get(muid=muid) + except User.DoesNotExist: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + from api.dashboard.achievement.rule_engine import RuleEvaluator + + evaluator = RuleEvaluator(str(target_user.id)) + all_progress = evaluator.get_all_progress() + + response_data = [ + { + "achievement_id": result.achievement_id, + "achievement_name": result.achievement_name, + "eligible": result.eligible, + "claimed": result.claimed, + "reason": result.reason, + "progress": result.progress, + } + for result in all_progress + ] + + return CustomResponse(response=response_data).get_success_response() + + +class DebugAchievementAPIView(APIView): + """Debug a specific achievement for a user (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Retrieve Debug Achievement.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def get(self, request, muid, achievement_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + try: + target_user = User.objects.get(muid=muid) + except User.DoesNotExist: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + from api.dashboard.achievement.rule_engine import RuleEvaluator + from db.achievement import UserIgKarma, UserStreak, UserSkillProgress + + evaluator = RuleEvaluator(str(target_user.id)) + result = evaluator.evaluate_achievement(achievement_id) + + if not result: + return CustomResponse( + general_message="No active rule found for this achievement" + ).get_failure_response() + + # Get additional debug data + ig_karma = list( + UserIgKarma.objects.filter(user_id=target_user.id).values( + "ig_id", "total_karma", "task_count" + ) + ) + streaks = list( + UserStreak.objects.filter(user_id=target_user.id).values( + "streak_type", "current_streak", "longest_streak" + ) + ) + skill_progress = list( + UserSkillProgress.objects.filter(user_id=target_user.id).values( + "skill_id", "completed_task_count", "total_karma" + ) + ) + + response_data = { + "evaluation": { + "achievement_id": result.achievement_id, + "achievement_name": result.achievement_name, + "eligible": result.eligible, + "reason": result.reason, + "progress": result.progress, + }, + "user_data": { + "ig_karma": ig_karma, + "streaks": streaks, + "skill_progress": skill_progress, + }, + } + + return CustomResponse(response=response_data).get_success_response() + + +class ManualIssueAPIView(APIView): + """Manually issue an achievement (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Create Manual Issue.", + responses={200: OpenApiResponse(description="Achievement manually issued to user")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + muid = request.data.get("muid") + achievement_id = request.data.get("achievement_id") + + if not muid or not achievement_id: + return CustomResponse( + general_message="muid and achievement_id are required" + ).get_failure_response() + + try: + target_user = User.objects.get(muid=muid) + except User.DoesNotExist: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + from mu_celery.achievement_tasks import manual_issue_achievement + + result = manual_issue_achievement( + user_id=str(target_user.id), + achievement_id=achievement_id, + performed_by=user_id, + ) + + if result["success"]: + return CustomResponse( + general_message=result["message"] + ).get_success_response() + else: + return CustomResponse( + general_message=result["message"] + ).get_failure_response() + + +class RevokeAchievementAPIView(APIView): + """Revoke an achievement (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Create Revoke Achievement.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + muid = request.data.get("muid") + achievement_id = request.data.get("achievement_id") + reason = request.data.get("reason") + + if not muid or not achievement_id: + return CustomResponse( + general_message="muid and achievement_id are required" + ).get_failure_response() + + try: + target_user = User.objects.get(muid=muid) + except User.DoesNotExist: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + from mu_celery.achievement_tasks import revoke_achievement + + result = revoke_achievement( + user_id=str(target_user.id), + achievement_id=achievement_id, + performed_by=user_id, + reason=reason, + ) + + if result["success"]: + return CustomResponse( + general_message=result["message"] + ).get_success_response() + else: + return CustomResponse( + general_message=result["message"] + ).get_failure_response() + + +class AuditLogAPIView(APIView): + """View audit logs for a user (admin)""" + + @extend_schema(tags=['Dashboard - Achievement'], description="Retrieve Audit Log.", + responses={200: inline_serializer("AchievementAuditLogResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.ListField( + child=inline_serializer("AchievementAuditLogItem", fields={ + "id": s.CharField(), + "achievement_id": s.CharField(), + "achievement_name": s.CharField(), + "action": s.CharField(help_text="e.g. ISSUED, REVOKED"), + "rule_version": s.IntegerField(allow_null=True), + "metadata": s.DictField(allow_null=True), + "performed_by": s.CharField(allow_null=True), + "created_at": s.DateTimeField(allow_null=True), + }), + help_text="Last 100 audit log entries for the given user's achievements", + ), + })}, + ) + def get(self, request, muid): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + try: + target_user = User.objects.get(muid=muid) + except User.DoesNotExist: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + audit_logs = ( + AchievementAuditLog.objects.filter(user_id=target_user.id) + .select_related("achievement") + .order_by("-created_at")[:100] + ) + + data = [ + { + "id": str(log.id), + "achievement_id": str(log.achievement_id), + "achievement_name": log.achievement.name, + "action": log.action, + "rule_version": log.rule_version, + "metadata": log.metadata, + "performed_by": str(log.performed_by_id) if log.performed_by_id else None, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + for log in audit_logs + ] + + return CustomResponse(response=data).get_success_response() + + + +class AchievementIssueBulkAPIView(APIView): + from rest_framework.parsers import MultiPartParser, FormParser + parser_classes = [MultiPartParser, FormParser] + + @extend_schema(tags=['Dashboard - Achievement'], description="Create Achievement Issue Bulk.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + excel_file = request.FILES.get('excel_file') + if not excel_file: + return CustomResponse( + general_message="No file uploaded" + ).get_failure_response() + + try: + wb = openpyxl.load_workbook(excel_file) + sheet = wb.active + + headers = [cell.value for cell in sheet[1]] + required_headers = ['muid', 'achievement_id'] + + if not all(h in headers for h in required_headers): + return CustomResponse( + general_message=f"Missing required headers. Required: {required_headers}" + ).get_failure_response() + + muid_idx = headers.index('muid') + ach_idx = headers.index('achievement_id') + + success_count = 0 + failed_rows = [] + + from mu_celery.achievement_tasks import manual_issue_achievement + + for i, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2): + muid = row[muid_idx] + achievement_id = row[ach_idx] + + if not muid or not achievement_id: + continue + + try: + user = User.objects.filter(muid=muid).first() + if not user: + failed_rows.append({"row": i, "muid": muid, "reason": "User not found"}) + continue + + result = manual_issue_achievement( + user_id=str(user.id), + achievement_id=str(achievement_id), + performed_by=user_id + ) + + if result['success']: + success_count += 1 + else: + failed_rows.append({"row": i, "muid": muid, "reason": result['message']}) + + except Exception as e: + failed_rows.append({"row": i, "muid": muid, "reason": str(e)}) + + return CustomResponse( + response={ + "success_count": success_count, + "failed_count": len(failed_rows), + "failed_rows": failed_rows + }, + general_message="Bulk issue processing completed" + ).get_success_response() + + except Exception as e: + return CustomResponse( + general_message=f"Error processing file: {str(e)}" + ).get_failure_response() + + +class AchievementBulkImportTemplateAPIView(APIView): + @extend_schema(tags=['Dashboard - Achievement'], description="Retrieve Achievement Bulk Import Template.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def get(self, request): + wb = openpyxl.Workbook() + ws = wb.active + ws.append(['muid', 'achievement_id']) + + output = BytesIO() + wb.save(output) + output.seek(0) + + response = FileResponse(output, content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + response['Content-Disposition'] = 'attachment; filename=achievement_bulk_import_template.xlsx' + return response + + +class AchievementLogListAPIView(APIView): + @extend_schema(tags=['Dashboard - Achievement'], description="Retrieve Achievement Log List.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Invalid or missing token" + ).get_failure_response() + + logs = UserAchievementsLog.objects.select_related('user_id', 'achievement_id', 'updated_by').order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + logs, + request, + search_fields=['user_id__muid', 'user_id__first_name', 'achievement_id__name'], + sort_fields={'created_at': 'created_at'} + ) + + data = [] + for log in paginated_queryset.get('queryset'): + data.append({ + "id": str(log.id), + "muid": log.user_id.muid, + "user_name": log.user_id.full_name, + "achievement_name": log.achievement_id.name, + "is_issued": log.is_issued, + "created_at": log.created_at.isoformat() if log.created_at else None, + "issued_by": log.updated_by.full_name if log.updated_by else None + }) + + return CustomResponse().paginated_response( + data=data, + pagination=paginated_queryset.get('pagination') + ) + +class BulkClaimTaskAchievementAPIView(APIView): + permission_classes = [BackendApiKeyPermission] + + @extend_schema(tags=['Dashboard - Achievement'], description="Create Bulk Claim Task Achievement.", + responses={200: achievement_serializer.AchievementSerializer}, + ) + def post(self, request): + try: + today = datetime.now().date() + yesterday = today - timedelta(days=1) + + date_from_str = request.data.get("date_from", yesterday.isoformat()) + date_to_str = request.data.get("date_to", yesterday.isoformat()) + + # Validate date format and convert to date objects + date_from = datetime.fromisoformat(date_from_str).date() + date_to = datetime.fromisoformat(date_to_str).date() + + # Validate that the date range is logically correct + if date_from > date_to: + return CustomResponse( + general_message="Invalid date range: date_from cannot be after date_to." + ).get_failure_response() + bulk_check_and_issue_achievements.delay( + date_from_str=date_from_str, + date_to_str=date_to_str, + performed_by_id=None, + ) + + return CustomResponse( + general_message="Bulk sync job scheduled successfully." + ).get_success_response() + + except ValueError: + return CustomResponse( + general_message="Invalid date format. Use ISO format (YYYY-MM-DD)." + ).get_failure_response() + + except Exception as e: + return CustomResponse( + general_message=f"An unexpected error occurred: {str(e)}" + ).get_failure_response() diff --git a/api/dashboard/achievement/rule_engine.py b/api/dashboard/achievement/rule_engine.py new file mode 100644 index 000000000..5a75c8c68 --- /dev/null +++ b/api/dashboard/achievement/rule_engine.py @@ -0,0 +1,385 @@ +""" +Achievement Rule Engine + +Properties: +- Stateless: No side effects +- Deterministic: Same input = same output +- Versioned: Rules are immutable once created + +Usage: + from api.dashboard.achievement.rule_engine import RuleEvaluator + evaluator = RuleEvaluator(user_id="...") + eligible = evaluator.get_eligible_achievements() +""" +from typing import Optional, Dict, Any, List +from dataclasses import dataclass, field +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class EligibilityResult: + """Result of rule evaluation""" + eligible: bool + achievement_id: str + achievement_name: str + rule_version: int + reason: str + progress: Dict[str, Any] = field(default_factory=dict) + claimed: bool = False # True when the user has already claimed this achievement + + +class RuleEvaluator: + """ + Evaluates achievement rules against user state. + Stateless - fetches required data and evaluates. + """ + + def __init__(self, user_id: str): + self.user_id = user_id + self._cache = {} # Cache user data within single evaluation + + def get_eligible_achievements(self) -> List[EligibilityResult]: + """ + Get all achievements the user is eligible to claim. + Only returns achievements not already claimed. + """ + from db.achievement import AchievementRule, UserAchievementsLog + + results = [] + + # Get already claimed achievements + claimed_ids = set( + UserAchievementsLog.objects.filter(user_id=self.user_id).values_list( + "achievement_id", flat=True + ) + ) + + # Get all active rules + active_rules = AchievementRule.objects.filter(is_active=True).select_related( + "achievement" + ) + + for rule in active_rules: + # Skip if already claimed + if str(rule.achievement_id) in claimed_ids: + continue + + result = self.evaluate_rule(rule) + if result.eligible: + results.append(result) + + return results + + def get_all_progress(self) -> List[EligibilityResult]: + """ + Get progress towards all achievements (claimed or not). + Returns all results, not just eligible ones. + """ + from db.achievement import AchievementRule, UserAchievementsLog + + results = [] + + # Get already claimed achievements + claimed_ids = set( + UserAchievementsLog.objects.filter(user_id=self.user_id).values_list( + "achievement_id", flat=True + ) + ) + + # Get all active rules + active_rules = AchievementRule.objects.filter(is_active=True).select_related( + "achievement" + ) + + for rule in active_rules: + result = self.evaluate_rule(rule) + # Mark if already claimed without overriding the rule-based eligibility + if str(rule.achievement_id) in claimed_ids: + result.claimed = True + result.reason = "Already claimed" + results.append(result) + + return results + + def evaluate_rule(self, rule) -> EligibilityResult: + """Evaluate a single rule""" + evaluator = self._get_evaluator(rule.rule_type) + return evaluator(rule) + + def evaluate_achievement(self, achievement_id: str) -> Optional[EligibilityResult]: + """Evaluate eligibility for a specific achievement""" + from db.achievement import AchievementRule + + rule = ( + AchievementRule.objects.filter( + achievement_id=achievement_id, is_active=True + ) + .select_related("achievement") + .order_by("-version") + .first() + ) + + if not rule: + return None + + return self.evaluate_rule(rule) + + def _get_evaluator(self, rule_type: str): + """Get evaluator function for rule type""" + evaluators = { + "ig_karma": self._evaluate_ig_karma, + "skill": self._evaluate_skill, + "streak": self._evaluate_streak, + "milestone": self._evaluate_milestone, + "event": self._evaluate_event, + "task_completion": self._evaluate_task_completion, + } + return evaluators.get(rule_type, self._evaluate_unknown) + + def _evaluate_task_completion(self, rule) -> EligibilityResult: + """Evaluate task completion rule""" + conditions = rule.conditions + task_hashtag = conditions.get("task_hashtag") + + # Check if user has completed the task + from db.task import KarmaActivityLog + + has_completed = KarmaActivityLog.objects.filter( + user_id=self.user_id, + task__hashtag=task_hashtag, + appraiser_approved=True + ).exists() + + return EligibilityResult( + eligible=has_completed, + achievement_id=str(rule.achievement_id), + achievement_name=rule.achievement.name, + rule_version=rule.version, + reason=f"Task Completion: {task_hashtag}", + progress={ + "current": 1 if has_completed else 0, + "required": 1, + "percentage": 100 if has_completed else 0, + "task_hashtag": task_hashtag, + }, + ) + + def _evaluate_ig_karma(self, rule) -> EligibilityResult: + """Evaluate IG karma threshold rule""" + conditions = rule.conditions + ig_id = conditions.get("ig_id") + required_karma = conditions.get("required_karma", 0) + + user_ig_karma = self._get_user_ig_karma(ig_id) + current_karma = user_ig_karma.total_karma if user_ig_karma else 0 + + percentage = ( + min(100, int(current_karma / required_karma * 100)) + if required_karma + else 100 + ) + + return EligibilityResult( + eligible=current_karma >= required_karma, + achievement_id=str(rule.achievement_id), + achievement_name=rule.achievement.name, + rule_version=rule.version, + reason=f"IG Karma: {current_karma}/{required_karma}", + progress={ + "current": current_karma, + "required": required_karma, + "percentage": percentage, + "ig_id": ig_id, + }, + ) + + def _evaluate_skill(self, rule) -> EligibilityResult: + """Evaluate skill task count rule""" + conditions = rule.conditions + skill_id = conditions.get("skill_id") + required_tasks = conditions.get("required_tasks", 0) + + progress = self._get_skill_progress(skill_id) + current_tasks = progress.completed_task_count if progress else 0 + + percentage = ( + min(100, int(current_tasks / required_tasks * 100)) + if required_tasks + else 100 + ) + + return EligibilityResult( + eligible=current_tasks >= required_tasks, + achievement_id=str(rule.achievement_id), + achievement_name=rule.achievement.name, + rule_version=rule.version, + reason=f"Skill Tasks: {current_tasks}/{required_tasks}", + progress={ + "current": current_tasks, + "required": required_tasks, + "percentage": percentage, + "skill_id": skill_id, + }, + ) + + def _evaluate_streak(self, rule) -> EligibilityResult: + """Evaluate streak rule""" + conditions = rule.conditions + streak_type = conditions.get("streak_type", "daily_task") + required_streak = conditions.get("required_streak", 0) + + streak = self._get_user_streak(streak_type) + current_streak = streak.current_streak if streak else 0 + + percentage = ( + min(100, int(current_streak / required_streak * 100)) + if required_streak + else 100 + ) + + return EligibilityResult( + eligible=current_streak >= required_streak, + achievement_id=str(rule.achievement_id), + achievement_name=rule.achievement.name, + rule_version=rule.version, + reason=f"Streak: {current_streak}/{required_streak} days", + progress={ + "current": current_streak, + "required": required_streak, + "percentage": percentage, + "streak_type": streak_type, + }, + ) + + def _evaluate_milestone(self, rule) -> EligibilityResult: + """Evaluate total milestone rule (total karma, total tasks, etc.)""" + conditions = rule.conditions + milestone_type = conditions.get("milestone_type", "total_karma") + required_value = conditions.get("required_value", 0) + + current_value = 0 + if milestone_type == "total_karma": + wallet = self._get_wallet() + current_value = wallet.karma if wallet else 0 + elif milestone_type == "total_tasks": + current_value = self._get_total_task_count() + + percentage = ( + min(100, int(current_value / required_value * 100)) + if required_value + else 100 + ) + + return EligibilityResult( + eligible=current_value >= required_value, + achievement_id=str(rule.achievement_id), + achievement_name=rule.achievement.name, + rule_version=rule.version, + reason=f"{milestone_type}: {current_value}/{required_value}", + progress={ + "current": current_value, + "required": required_value, + "percentage": percentage, + "milestone_type": milestone_type, + }, + ) + + def _evaluate_event(self, rule) -> EligibilityResult: + """Evaluate event attendance rule""" + conditions = rule.conditions + event_name = conditions.get("event_name") + required_attendance = conditions.get("required_attendance", 1) + + # Count attendance from karma_activity_log + from db.task import KarmaActivityLog + + attendance = KarmaActivityLog.objects.filter( + user_id=self.user_id, task__event=event_name, appraiser_approved=True + ).count() + + percentage = ( + min(100, int(attendance / required_attendance * 100)) + if required_attendance + else 100 + ) + + return EligibilityResult( + eligible=attendance >= required_attendance, + achievement_id=str(rule.achievement_id), + achievement_name=rule.achievement.name, + rule_version=rule.version, + reason=f"Event Attendance: {attendance}/{required_attendance}", + progress={ + "current": attendance, + "required": required_attendance, + "percentage": percentage, + "event_name": event_name, + }, + ) + + def _evaluate_unknown(self, rule) -> EligibilityResult: + """Handle unknown rule types""" + return EligibilityResult( + eligible=False, + achievement_id=str(rule.achievement_id), + achievement_name=rule.achievement.name if rule.achievement else "Unknown", + rule_version=rule.version, + reason=f"Unknown rule type: {rule.rule_type}", + ) + + # ======================================================================== + # Data fetching methods (cached within single evaluation) + # ======================================================================== + + def _get_user_ig_karma(self, ig_id: str): + """Get user's karma for a specific IG""" + from db.achievement import UserIgKarma + + cache_key = f"ig_karma_{ig_id}" + if cache_key not in self._cache: + self._cache[cache_key] = UserIgKarma.objects.filter( + user_id=self.user_id, ig_id=ig_id + ).first() + return self._cache[cache_key] + + def _get_skill_progress(self, skill_id: str): + """Get user's progress for a specific skill""" + from db.achievement import UserSkillProgress + + cache_key = f"skill_{skill_id}" + if cache_key not in self._cache: + self._cache[cache_key] = UserSkillProgress.objects.filter( + user_id=self.user_id, skill_id=skill_id + ).first() + return self._cache[cache_key] + + def _get_user_streak(self, streak_type: str): + """Get user's streak of a specific type""" + from db.achievement import UserStreak + + cache_key = f"streak_{streak_type}" + if cache_key not in self._cache: + self._cache[cache_key] = UserStreak.objects.filter( + user_id=self.user_id, streak_type=streak_type + ).first() + return self._cache[cache_key] + + def _get_wallet(self): + """Get user's wallet""" + from db.task import Wallet + + if "wallet" not in self._cache: + self._cache["wallet"] = Wallet.objects.filter(user_id=self.user_id).first() + return self._cache["wallet"] + + def _get_total_task_count(self) -> int: + """Get user's total approved task count""" + from db.task import KarmaActivityLog + + if "total_tasks" not in self._cache: + self._cache["total_tasks"] = KarmaActivityLog.objects.filter( + user_id=self.user_id, appraiser_approved=True + ).count() + return self._cache["total_tasks"] diff --git a/api/dashboard/achievement/urls.py b/api/dashboard/achievement/urls.py index 6e93fd7fe..f59345f3f 100644 --- a/api/dashboard/achievement/urls.py +++ b/api/dashboard/achievement/urls.py @@ -2,10 +2,47 @@ from . import achievement_views urlpatterns = [ + # ===== USER-FACING ENDPOINTS ===== + # List all achievements path('list/', achievement_views.AchievementListAPIView.as_view(), name='achievement-list'), + # View eligible achievements for current user (ready to claim) + path('eligible/', achievement_views.EligibleAchievementsAPIView.as_view(), name='achievement-eligible'), + # Claim an achievement (user action) + path('claim//', achievement_views.ClaimAchievementAPIView.as_view(), name='achievement-claim'), + # View user's claimed achievements + path('list/user//', achievement_views.UserAchievementsListAPIView.as_view(), name='achievements-user'), + # View progress towards all achievements + path('progress/', achievement_views.UserProgressAPIView.as_view(), name='achievement-progress'), + + # ===== ADMIN ENDPOINTS ===== + # Achievement CRUD path('create/', achievement_views.AchievementCreateAPIView.as_view(), name='achievements-create'), path('update//', achievement_views.AchievementUpdateAPIView.as_view(), name='achievements-update'), path('delete//', achievement_views.AchievementDeleteAPIView.as_view(), name='achievements-delete'), - path('list/user//', achievement_views.UserAchievementsListAPIView.as_view(), name='achievements-user'), + + # Rule Management + path('rules/', achievement_views.AchievementRuleListAPIView.as_view(), name='achievement-rules-list'), + path('rules/create/', achievement_views.AchievementRuleCreateAPIView.as_view(), name='achievement-rules-create'), + path('rules//', achievement_views.AchievementRuleDetailAPIView.as_view(), name='achievement-rules-detail'), + path('rules//deactivate/', achievement_views.AchievementRuleDeactivateAPIView.as_view(), name='achievement-rules-deactivate'), + path('rules//activate/', achievement_views.AchievementRuleActivateAPIView.as_view(), name='achievement-rules-activate'), + + # Simulation & Debug + path('simulate//', achievement_views.SimulateRulesAPIView.as_view(), name='achievement-simulate'), + path('debug///', achievement_views.DebugAchievementAPIView.as_view(), name='achievement-debug'), + + # Manual Operations (Admin only) + path('manual-issue/', achievement_views.ManualIssueAPIView.as_view(), name='achievement-manual-issue'), + path('revoke/', achievement_views.RevokeAchievementAPIView.as_view(), name='achievement-revoke'), + + # Audit Logs + path('audit//', achievement_views.AuditLogAPIView.as_view(), name='achievement-audit'), + + # Legacy endpoint (for VC issuance) path('issue-vc/', achievement_views.UserAchievementsIssueAPIView.as_view(), name='achievements-issue'), -] \ No newline at end of file + path('bulk-issue/', achievement_views.AchievementIssueBulkAPIView.as_view(), name='achievements-bulk-issue'), + path('bulk-issue/template/', achievement_views.AchievementBulkImportTemplateAPIView.as_view(), name='achievements-bulk-issue-template'), + path('issued-log/', achievement_views.AchievementLogListAPIView.as_view(), name='achievements-issued-log'), + path('bulk-claim/', achievement_views.BulkClaimTaskAchievementAPIView.as_view(), name='achievement-bulk-claim'), +] + diff --git a/api/dashboard/affiliation/affiliation_views.py b/api/dashboard/affiliation/affiliation_views.py index f2e6a2a35..66790ac4e 100644 --- a/api/dashboard/affiliation/affiliation_views.py +++ b/api/dashboard/affiliation/affiliation_views.py @@ -7,6 +7,7 @@ from utils.types import RoleType from utils.utils import CommonUtils from .serializers import AffiliationCUDSerializer, AffiliationListSerializer +from drf_spectacular.utils import extend_schema @@ -16,6 +17,11 @@ class AffiliationCRUDAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Affiliation'], + description="Retrieve Affiliation C R U D.", + responses={200: AffiliationListSerializer}, + ) def get(self, request): affiliation = OrgAffiliation.objects.all() paginated_queryset = CommonUtils.get_paginated_queryset( @@ -39,6 +45,12 @@ def get(self, request): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Affiliation'], + description="Create Affiliation C R U D.", + request=AffiliationCUDSerializer, + responses={200: AffiliationListSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -63,6 +75,11 @@ def post(self, request): ).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Affiliation'], + description="Update Affiliation C R U D.", + responses={200: AffiliationCUDSerializer}, + ) def put(self, request, affiliation_id): user_id = JWTUtils.fetch_user_id(request) @@ -94,6 +111,9 @@ def put(self, request, affiliation_id): ).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Affiliation'], description="Delete Affiliation C R U D.", + responses={200: AffiliationListSerializer}, + ) def delete(self, request, affiliation_id): affiliation = OrgAffiliation.objects.filter( diff --git a/api/dashboard/campus/analytics_views.py b/api/dashboard/campus/analytics_views.py new file mode 100644 index 000000000..129220fc5 --- /dev/null +++ b/api/dashboard/campus/analytics_views.py @@ -0,0 +1,144 @@ +from rest_framework.views import APIView +from rest_framework import status +from utils.response import CustomResponse +from utils.permission import CustomizePermission +from utils.types import RoleType +from .dash_campus_helper import get_campus_context, campus_staff_required +from db.task import KarmaActivityLog +from django.db.models.functions import TruncDate +from django.db.models import Sum +from datetime import timedelta +from utils.utils import DateTimeUtils +from django.utils import timezone +from db.organization import UserOrganizationLink +from api.dashboard.learningcircle import services as lc_services +from django.core.cache import cache +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s + +class CampusKarmaTrendAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Karma Trend.", + responses={200: inline_serializer( + name="CampusKarmaTrendResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusKarmaTrendItem", + fields={ + "date": s.CharField(allow_null=True), + "total_karma": s.IntegerField(), + }, + many=True, + ), + }, + )}, + ) + def get(self, request): + org, error = get_campus_context(request) + if error: return error + + try: + days = int(request.query_params.get('days', 7)) + days = min(max(days, 1), 365) + except ValueError: + days = 7 + cache_key = f"campus_karma_trend_{org.id}_{days}d" + cached_data = cache.get(cache_key) + + if cached_data: + return CustomResponse(response=cached_data).get_success_response() + + start_date = DateTimeUtils.get_current_utc_time() - timedelta(days=days) + + qs = KarmaActivityLog.objects.filter( + user__user_organization_link_user__org=org, + created_at__gte=start_date + ).annotate( + date=TruncDate('created_at') + ).values('date').annotate( + total_karma=Sum('karma') + ).order_by('date') + + data = [ + { + "date": item["date"].isoformat() if item["date"] else None, + "total_karma": item["total_karma"] + } + for item in qs + ] + + cache.set(cache_key, data, 60 * 15) # Cache for 15 minutes + + return CustomResponse(response=data).get_success_response() + +class CampusGrowthAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Growth.", + responses={200: inline_serializer( + name="CampusGrowthResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusGrowthItem", + fields={ + "key": s.CharField(), + "label": s.CharField(), + "value": s.IntegerField(), + "delta": s.IntegerField(), + "delta_type": s.CharField(), + "period": s.CharField(), + }, + many=True, + ), + }, + )}, + ) + def get(self, request): + org, error = get_campus_context(request) + if error: return error + + cache_key = f"campus_growth_{org.id}" + cached_data = cache.get(cache_key) + if cached_data: + return CustomResponse(response=cached_data).get_success_response() + + current_month = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + previous_month = (current_month - timedelta(days=1)).replace(day=1) + + current_members = UserOrganizationLink.objects.filter(org=org, is_alumni=False, created_at__gte=current_month).count() + prev_members = UserOrganizationLink.objects.filter(org=org, is_alumni=False, created_at__gte=previous_month, created_at__lt=current_month).count() + + current_lcs = lc_services.get_campus_learning_circles(org.id).filter(created_at__gte=current_month).count() + prev_lcs = lc_services.get_campus_learning_circles(org.id).filter(created_at__gte=previous_month, created_at__lt=current_month).count() + + data = [ + { + "key": "members", + "label": "Members joined", + "value": current_members, + "delta": current_members - prev_members, + "delta_type": "increase" if current_members >= prev_members else "decrease", + "period": "30d" + }, + { + "key": "learning_circles", + "label": "Learning Circles formed", + "value": current_lcs, + "delta": current_lcs - prev_lcs, + "delta_type": "increase" if current_lcs >= prev_lcs else "decrease", + "period": "30d" + } + ] + + cache.set(cache_key, data, 60 * 15) # Cache for 15 minutes + + return CustomResponse(response=data).get_success_response() diff --git a/api/dashboard/campus/campus_views.py b/api/dashboard/campus/campus_views.py index f4c72280e..b7a477ed6 100644 --- a/api/dashboard/campus/campus_views.py +++ b/api/dashboard/campus/campus_views.py @@ -1,631 +1,1362 @@ -from django.db.models import Count, F -from django.db.models import Q -from rest_framework.views import APIView - -from db.organization import Organization, UserOrganizationLink -from db.task import Level, Wallet, InterestGroup -from db.user import User, Role, UserRoleLink -from utils.permission import CustomizePermission, JWTUtils, role_required -from utils.response import CustomResponse -from utils.types import OrganizationType, RoleType -from utils.utils import CommonUtils -from . import serializers -from .dash_campus_helper import get_user_college_link - - -class CampusDetailsPublicAPI(APIView): - """ - Campus Details API - - This API view allows authorized users with specific roles (Campus Lead or Enabler) - to access details about their campus - - Attributes: - authentication_classes (list): A list containing the CustomizePermission class for authentication. - - Method: - get(request): Handles GET requests to retrieve campus details for the authenticated user. - """ - - authentication_classes = [CustomizePermission] - - # Use the role_required decorator to specify the allowed roles for this view - # @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def get(self, request, org_id): - - if not org_id: - return CustomResponse( - general_message="College not found" - ).get_failure_response() - - org = Organization.objects.filter( - id=org_id, org_type=OrganizationType.COLLEGE.value - ).first() - - if org is None: - return CustomResponse( - general_message="College not found" - ).get_failure_response() - - serializer = serializers.CampusDetailsPublicSerializer(org, many=False) - - return CustomResponse(response=serializer.data).get_success_response() - - -class CampusDetailsAPI(APIView): - """ - Campus Details API - - This API view allows authorized users with specific roles (Campus Lead or Enabler) - to access details about their campus - - Attributes: - authentication_classes (list): A list containing the CustomizePermission class for authentication. - - Method: - get(request): Handles GET requests to retrieve campus details for the authenticated user. - """ - - authentication_classes = [CustomizePermission] - - # Use the role_required decorator to specify the allowed roles for this view - @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def get(self, request): - # Fetch the user's ID from the request using JWTUtils - user_id = JWTUtils.fetch_user_id(request) - - # Get the user's organization link using the user ID - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - - # Check if the user's organization link is None - if user_org_link.org is None: - # If it is None, return a failure response with a specific message - return CustomResponse( - general_message="Campus lead has no college" - ).get_failure_response() - - # # Serialize the user's organization link using the CampusDetailsSerializer - serializer = serializers.CampusDetailsSerializer(user_org_link, many=False) - - # Return a success response with the serialized data - return CustomResponse(response=serializer.data).get_success_response() - - -class CampusStudentInEachLevelAPI(APIView): - authentication_classes = [CustomizePermission] - - # @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def get(self, request, org_id=None): - if org_id: - org = Organization.objects.filter( - id=org_id, org_type=OrganizationType.COLLEGE.value - ).first() - if not org: - return CustomResponse( - general_message="College not found" - ).get_failure_response() - else: - user_id = JWTUtils.fetch_user_id(request) - - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - - if user_org_link.org is None: - return CustomResponse( - general_message="Campus lead has no college" - ).get_failure_response() - org = user_org_link.org - - level_with_student_count = Level.objects.annotate( - students=Count( - "user_lvl_link_level__user", - filter=Q( - user_lvl_link_level__user__user_organization_link_user__org=org - ), - ) - ).values(level=F("level_order"), students=F("students")) - - return CustomResponse(response=level_with_student_count).get_success_response() - - -class CampusStudentDetailsAPI(APIView): - authentication_classes = [CustomizePermission] - - @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def get(self, request): - user_id = JWTUtils.fetch_user_id(request) - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - is_alumni = request.query_params.get("is_alumni") - - if user_org_link.org is None: - return CustomResponse( - general_message="Campus lead has no college" - ).get_failure_response() - if is_alumni: - rank = ( - Wallet.objects.filter( - user__user_organization_link_user__org=user_org_link.org, - user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - user__user_organization_link_user__is_alumni=is_alumni, - ) - .distinct() - .order_by("-karma", "-created_at") - .values( - "user_id", - "karma", - ) - ) - - ranks = {user["user_id"]: i + 1 for i, user in enumerate(rank)} - - user_org_links = ( - User.objects.filter( - user_organization_link_user__org=user_org_link.org, - user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - user_organization_link_user__is_alumni=is_alumni, - ) - .distinct() - .annotate( - user_id=F("id"), - email_=F("email"), - mobile_=F("mobile"), - karma=F("wallet_user__karma"), - level=F("user_lvl_link_user__level__name"), - join_date=F("created_at"), - last_karma_gained=F("wallet_user__karma_last_updated_at"), - department=F("user_organization_link_user__department__title"), - graduation_year=F("user_organization_link_user__graduation_year"), - is_alumni=F("user_organization_link_user__is_alumni"), - ) - ) - else: - rank = ( - Wallet.objects.filter( - user__user_organization_link_user__org=user_org_link.org, - user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - ) - .distinct() - .order_by("-karma", "-created_at") - .values( - "user_id", - "karma", - ) - ) - - ranks = {user["user_id"]: i + 1 for i, user in enumerate(rank)} - - user_org_links = ( - User.objects.filter( - user_organization_link_user__org=user_org_link.org, - user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - ) - .distinct() - .annotate( - user_id=F("id"), - email_=F("email"), - mobile_=F("mobile"), - karma=F("wallet_user__karma"), - level=F("user_lvl_link_user__level__name"), - join_date=F("created_at"), - last_karma_gained=F("wallet_user__karma_last_updated_at"), - department=F("user_organization_link_user__department__title"), - graduation_year=F("user_organization_link_user__graduation_year"), - is_alumni=F("user_organization_link_user__is_alumni"), - ) - ) - - paginated_queryset = CommonUtils.get_paginated_queryset( - user_org_links, - request, - ["full_name", "level"], - { - "full_name": "full_name", - "muid": "muid", - "karma": "wallet_user__karma", - "level": "user_lvl_link_user__level__level_order", - # "is_active": "karma_activity_log_user__created_at", - "join_date": "created_at", - "email": "email_", - "mobile": "mobile_", - "is_alumni": "is_alumni", - }, - ) - - serializer = serializers.CampusStudentDetailsSerializer( - paginated_queryset.get("queryset"), many=True, context={"ranks": ranks} - ) - return CustomResponse( - response={ - "data": serializer.data, - "pagination": paginated_queryset.get("pagination"), - } - ).get_success_response() - - -class CampusStudentDetailsCSVAPI(APIView): - authentication_classes = [CustomizePermission] - - @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def get(self, request): - user_id = JWTUtils.fetch_user_id(request) - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - is_alumni = request.query_params.get("is_alumni") - - if user_org_link.org is None: - return CustomResponse( - general_message="Campus lead has no college" - ).get_failure_response() - - if is_alumni: - rank = ( - Wallet.objects.filter( - user__user_organization_link_user__org=user_org_link.org, - user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - user__user_organization_link_user__is_alumni=is_alumni, - ) - .distinct() - .order_by("-karma", "-created_at") - .values( - "user_id", - "karma", - ) - ) - - ranks = {user["user_id"]: i + 1 for i, user in enumerate(rank)} - - user_org_links = ( - User.objects.filter( - user_organization_link_user__org=user_org_link.org, - user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - user_organization_link_user__is_alumni=is_alumni, - ) - .distinct() - .annotate( - user_id=F("id"), - email_=F("email"), - mobile_=F("mobile"), - karma=F("wallet_user__karma"), - level=F("user_lvl_link_user__level__name"), - join_date=F("created_at"), - last_karma_gained=F("wallet_user__karma_last_updated_at"), - department=F("user_organization_link_user__department__title"), - graduation_year=F("user_organization_link_user__graduation_year"), - is_alumni=F("user_organization_link_user__is_alumni"), - ) - ) - else: - rank = ( - Wallet.objects.filter( - user__user_organization_link_user__org=user_org_link.org, - user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - ) - .distinct() - .order_by("-karma", "-created_at") - .values( - "user_id", - "karma", - ) - ) - - ranks = {user["user_id"]: i + 1 for i, user in enumerate(rank)} - - user_org_links = ( - User.objects.filter( - user_organization_link_user__org=user_org_link.org, - user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - ) - .distinct() - .annotate( - user_id=F("id"), - email_=F("email"), - mobile_=F("mobile"), - karma=F("wallet_user__karma"), - level=F("user_lvl_link_user__level__name"), - join_date=F("created_at"), - last_karma_gained=F("wallet_user__karma_last_updated_at"), - department=F("user_organization_link_user__department__title"), - graduation_year=F("user_organization_link_user__graduation_year"), - is_alumni=F("user_organization_link_user__is_alumni"), - ) - ) - - paginated_queryset = CommonUtils.get_paginated_queryset( - user_org_links, - request, - ["full_name", "level"], - { - "full_name": "full_name", - "muid": "muid", - "karma": "wallet_user__karma", - "level": "user_lvl_link_user__level__level_order", - # "is_active": "karma_activity_log_user__created_at", - "join_date": "created_at", - "email": "email_", - "mobile": "mobile_", - "is_alumni": "is_alumni", - }, - ) - - serializer = serializers.CampusStudentDetailsSerializer( - user_org_links, many=True, context={"ranks": ranks} - ) - return CommonUtils.generate_csv(serializer.data, "Campus Student Details") - - -class WeeklyKarmaAPI(APIView): - authentication_classes = [CustomizePermission] - - # @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def get(self, request, org_id=None): - if org_id: - org = Organization.objects.filter( - id=org_id, org_type=OrganizationType.COLLEGE.value - ).first() - if not org: - return CustomResponse( - general_message="College not found" - ).get_failure_response() - else: - user_id = JWTUtils.fetch_user_id(request) - - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - - if user_org_link.org is None: - return CustomResponse( - general_message="Campus lead has no college" - ).get_failure_response() - org = user_org_link.org - - serializer = serializers.WeeklyKarmaSerializer(org, many=False) - return CustomResponse(response=serializer.data).get_success_response() - - -class ChangeStudentTypeAPI(APIView): - authentication_classes = [CustomizePermission] - - @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def patch(self, request, member_id): - user_id = JWTUtils.fetch_user_id(request) - - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - user_org_link_obj = UserOrganizationLink.objects.filter( - user__id=member_id, - org=user_org_link.org, - org__org_type=OrganizationType.COLLEGE.value, - ).first() - - serializer = serializers.ChangeStudentTypeSerializer( - user_org_link_obj, data=request.data - ) - if serializer.is_valid(): - serializer.save() - return CustomResponse( - general_message="Student Type updated successfully" - ).get_success_response() - return CustomResponse(message=serializer.errors).get_failure_response() - - -class TransferLeadRoleAPI(APIView): - authentication_classes = [CustomizePermission] - - @role_required([RoleType.CAMPUS_LEAD.value]) - def post(self, request): - user_id = JWTUtils.fetch_user_id(request) - new_lead_muid = request.data.get("new_lead_muid", None) - if new_lead_muid is None: - return CustomResponse( - general_message="Required data is missing" - ).get_failure_response() - - new_lead = User.objects.filter(muid=new_lead_muid).first() - if new_lead is None: - return CustomResponse( - general_message="Can't find the user" - ).get_failure_response() - - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - validate_new_lead = UserOrganizationLink.objects.filter( - user__id=new_lead.id, - org=user_org_link.org, - org__org_type=OrganizationType.COLLEGE.value, - is_alumni=False, - ).first() - if validate_new_lead is None: - return CustomResponse( - general_message="Can't find the user in your college" - ).get_failure_response() - - role_id = Role.objects.filter(title=RoleType.CAMPUS_LEAD.value).first() - if role_id is None: - return CustomResponse( - general_message="Can't find the role" - ).get_failure_response() - role_id = role_id.id - - UserRoleLink.objects.filter( - user__id=user_id, - role__id=role_id, - ).delete() - - serializer = serializers.UserRoleLinkSerializer( - data={ - "user": new_lead.id, - "role": role_id, - }, - context={"user_id": user_id}, - ) - if serializer.is_valid(): - serializer.save() - return CustomResponse( - general_message="Assigned new Campus Lead successfully" - ).get_success_response() - return CustomResponse(message=serializer.errors).get_failure_response() - - -class TransferEnablerRoleAPI(APIView): - authentication_classes = [CustomizePermission] - - @role_required([RoleType.CAMPUS_LEAD.value]) - def post(self, request): - user_id = JWTUtils.fetch_user_id(request) - new_enabler_muid = request.data.get("new_enabler_muid", None) - if new_enabler_muid is None: - return CustomResponse( - general_message="Required data is missing" - ).get_failure_response() - - new_enabler = User.objects.filter(muid=new_enabler_muid).first() - if new_enabler is None: - return CustomResponse( - general_message="Can't find the user" - ).get_failure_response() - - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - validate_new_enabler = UserOrganizationLink.objects.filter( - user__id=new_enabler.id, - org=user_org_link.org, - org__org_type=OrganizationType.COLLEGE.value, - is_alumni=False, - ).first() - - if validate_new_enabler is None: - return CustomResponse( - general_message="Can't find the user in your college" - ).get_failure_response() - - role_id = Role.objects.filter(title=RoleType.LEAD_ENABLER.value).first() - if role_id is None: - return CustomResponse( - general_message="Can't find the role" - ).get_failure_response() - role_id = role_id.id - - current_enabler = UserRoleLink.objects.filter( - user__user_organization_link_user__org=user_org_link.org, - user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - role__id=role_id, - ).first() - if current_enabler: - current_enabler.delete() - - serializer = serializers.UserRoleLinkSerializer( - data={ - "user": new_enabler.id, - "role": role_id, - }, - context={"user_id": user_id}, - ) - if serializer.is_valid(): - serializer.save() - return CustomResponse( - general_message="Assigned new Enabler Lead successfully" - ).get_success_response() - return CustomResponse(message=serializer.errors).get_failure_response() - - -class TransferIGRoleAPI(APIView): - authentication_classes = [CustomizePermission] - - @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def get(self, request): - user_id = JWTUtils.fetch_user_id(request) - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - ig_list = ( - User.objects.filter( - user_organization_link_user__org=user_org_link.org, - user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - ) - .values_list("user_ig_link_user__ig__code", flat=True) - .distinct() - ) - - return CustomResponse(response={"ig_list": ig_list}).get_success_response() - - @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) - def post(self, request): - user_id = JWTUtils.fetch_user_id(request) - new_ig_muid = request.data.get("new_ig_muid", None) - ig_code = request.data.get("ig_code", None) - - if new_ig_muid is None or ig_code is None: - return CustomResponse( - general_message="Required data is missing" - ).get_failure_response() - - new_ig = User.objects.filter(muid=new_ig_muid).first() - if new_ig is None: - return CustomResponse( - general_message="Can't find the user" - ).get_failure_response() - - if not (user_org_link := get_user_college_link(user_id)): - return CustomResponse( - general_message="User have no organization" - ).get_failure_response() - validate_ig = UserOrganizationLink.objects.filter( - user__id=new_ig.id, - org=user_org_link.org, - org__org_type=OrganizationType.COLLEGE.value, - is_alumni=False, - ).first() - if validate_ig is None: - return CustomResponse( - general_message="Can't find the user in your college" - ).get_failure_response() - - # need to change title according to the ig role - # below code filter role for title=ig_code+CampusLead - role_id = Role.objects.filter(title=f"{ig_code}CampusLead").first() - if role_id is None: - return CustomResponse( - general_message="Can't find the role" - ).get_failure_response() - role_id = role_id.id - - current_ig = UserRoleLink.objects.filter( - user__user_organization_link_user__org=user_org_link.org, - user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - role__id=role_id, - ).first() - if current_ig: - current_ig.delete() - - serializer = serializers.UserRoleLinkSerializer( - data={ - "user": new_ig.id, - "role": role_id, - }, - context={"user_id": user_id}, - ) - if serializer.is_valid(): - serializer.save() - return CustomResponse( - general_message="Assigned new Ig lead successfully" - ).get_success_response() - return CustomResponse(message=serializer.errors).get_failure_response() +from django.db.models import Count, F,Sum, Subquery, OuterRef +from django.db.models import Q +from django.db import transaction +from rest_framework.views import APIView +from collections import defaultdict +import uuid +from db.organization import Organization, UserOrganizationLink +from db.task import Level, Wallet, InterestGroup +from db.campus import CampusIGChapter, CampusSocialLink +from db.user import User, Role, UserRoleLink +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import OrganizationType, RoleType +from utils.utils import CommonUtils +from . import serializers +from .dash_campus_helper import get_user_college_link, get_campus_ig_chapters, campus_staff_required, assign_ig_campus_lead +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s + + +class CampusListAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus List.", + responses={200: serializers.CampusListSerializer}, + ) + def get(self, request): + campuses = Organization.objects.filter(org_type=OrganizationType.COLLEGE.value) + paginated_queryset = CommonUtils.get_paginated_queryset( + campuses, + request, + ["title", "code"], + {"title": "title", "code": "code"} + ) + serializer = serializers.CampusListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class CampusDetailsPublicAPI(APIView): + """ + Campus Details API + + This API view allows authorized users with specific roles (Campus Lead or Enabler) + to access details about their campus + + Attributes: + authentication_classes (list): A list containing the CustomizePermission class for authentication. + + Method: + get(request): Handles GET requests to retrieve campus details for the authenticated user. + """ + + authentication_classes = [CustomizePermission] + + # Use the role_required decorator to specify the allowed roles for this view + # @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Details Public.", + responses={200: serializers.CampusDetailsPublicSerializer}, + ) + def get(self, request, org_id): + + if not org_id: + return CustomResponse( + general_message="College not found" + ).get_failure_response() + + org = Organization.objects.filter( + id=org_id, org_type=OrganizationType.COLLEGE.value + ).first() + + if org is None: + return CustomResponse( + general_message="College not found" + ).get_failure_response() + + serializer = serializers.CampusDetailsPublicSerializer(org, many=False) + + return CustomResponse(response=serializer.data).get_success_response() + + +class CampusDetailsAPI(APIView): + """ + Campus Details API + + This API view allows authorized users with specific roles (Campus Lead or Enabler) + to access details about their campus + + Attributes: + authentication_classes (list): A list containing the CustomizePermission class for authentication. + + Method: + get(request): Handles GET requests to retrieve campus details for the authenticated user. + """ + + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Details.", + responses={200: serializers.CampusDetailsSerializer}, + ) + def get(self, request): + # Fetch the user's ID from the request using JWTUtils + user_id = JWTUtils.fetch_user_id(request) + + # Get the user's organization link using the user ID + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + # Check if the user's organization link is None + if user_org_link.org is None: + # If it is None, return a failure response with a specific message + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + # # Serialize the user's organization link using the CampusDetailsSerializer + serializer = serializers.CampusDetailsSerializer(user_org_link, many=False) + + # Return a success response with the serialized data + return CustomResponse(response=serializer.data).get_success_response() + + +class CampusStudentInEachLevelAPI(APIView): + authentication_classes = [CustomizePermission] + + # @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Student In Each Level.", + responses={200: inline_serializer( + name="CampusStudentInEachLevelResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusStudentLevelItem", + fields={ + "level": s.IntegerField(), + "students": s.IntegerField(), + }, + many=True, + ), + }, + )}, + ) + def get(self, request, org_id=None): + if org_id: + org = Organization.objects.filter( + id=org_id, org_type=OrganizationType.COLLEGE.value + ).first() + if not org: + return CustomResponse( + general_message="College not found" + ).get_failure_response() + else: + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + org = user_org_link.org + + level_with_student_count = Level.objects.annotate( + students=Count( + "user_lvl_link_level__user", + filter=Q( + user_lvl_link_level__user__user_organization_link_user__org=org + ), + ) + ).values(level=F("level_order"), students=F("students")) + + return CustomResponse(response=level_with_student_count).get_success_response() + + +class CampusStudentDetailsAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Student Details.", + responses={200: serializers.CampusStudentDetailsSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + # Build dynamic filters from query params + is_alumni = request.query_params.get("is_alumni") + ig = request.query_params.get("ig") + category = request.query_params.get("category") + + wallet_filters = Q( + user__user_organization_link_user__org=user_org_link.org, + user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + ) + user_filters = Q( + user_organization_link_user__org=user_org_link.org, + user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + ) + + if is_alumni is not None: + is_alumni_filter = str(is_alumni).lower() == "true" + wallet_filters &= Q(user__user_organization_link_user__is_alumni=is_alumni_filter) + user_filters &= Q(user_organization_link_user__is_alumni=is_alumni_filter) + + if ig is not None: + wallet_filters &= Q(user__user_ig_link_user__ig__id=ig) + user_filters &= Q(user_ig_link_user__ig__id=ig) + + if category is not None: + wallet_filters &= Q(user__user_ig_link_user__ig__category=category) + user_filters &= Q(user_ig_link_user__ig__category=category) + + rank = ( + Wallet.objects.filter(wallet_filters) + .distinct() + .order_by("-karma", "-created_at") + .values("user_id", "karma") + ) + ranks = {user["user_id"]: i + 1 for i, user in enumerate(rank)} + + user_org_links = ( + User.objects.filter(user_filters) + .distinct() + .annotate( + user_id=F("id"), + email_=F("email"), + mobile_=F("mobile"), + karma=F("wallet_user__karma"), + level=F("user_lvl_link_user__level__name"), + join_date=F("created_at"), + last_karma_gained=F("wallet_user__karma_last_updated_at"), + department=F("user_organization_link_user__department__title"), + graduation_year=F("user_organization_link_user__graduation_year"), + is_alumni=F("user_organization_link_user__is_alumni"), + ) + ) + + paginated_queryset = CommonUtils.get_paginated_queryset( + user_org_links, + request, + ["full_name", "level"], + { + "full_name": "full_name", + "muid": "muid", + "karma": "wallet_user__karma", + "level": "user_lvl_link_user__level__level_order", + # "is_active": "karma_activity_log_user__created_at", + "join_date": "created_at", + "email": "email_", + "mobile": "mobile_", + "is_alumni": "is_alumni", + }, + ) + + serializer = serializers.CampusStudentDetailsSerializer( + paginated_queryset.get("queryset"), many=True, context={"ranks": ranks} + ) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class CampusStudentDetailsCSVAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Student Details CSV.", + responses={200: serializers.CampusStudentDetailsSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + # Build dynamic filters from query params + is_alumni = request.query_params.get("is_alumni") + ig = request.query_params.get("ig") + category = request.query_params.get("category") + + wallet_filters = Q( + user__user_organization_link_user__org=user_org_link.org, + user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + ) + user_filters = Q( + user_organization_link_user__org=user_org_link.org, + user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + ) + + if is_alumni is not None: + is_alumni_filter = str(is_alumni).lower() == "true" + wallet_filters &= Q(user__user_organization_link_user__is_alumni=is_alumni_filter) + user_filters &= Q(user_organization_link_user__is_alumni=is_alumni_filter) + + if ig is not None: + wallet_filters &= Q(user__user_ig_link_user__ig__id=ig) + user_filters &= Q(user_ig_link_user__ig__id=ig) + + if category is not None: + wallet_filters &= Q(user__user_ig_link_user__ig__category=category) + user_filters &= Q(user_ig_link_user__ig__category=category) + + rank = ( + Wallet.objects.filter(wallet_filters) + .distinct() + .order_by("-karma", "-created_at") + .values("user_id", "karma") + ) + ranks = {user["user_id"]: i + 1 for i, user in enumerate(rank)} + + user_org_links = ( + User.objects.filter(user_filters) + .distinct() + .annotate( + user_id=F("id"), + email_=F("email"), + mobile_=F("mobile"), + karma=F("wallet_user__karma"), + level=F("user_lvl_link_user__level__name"), + join_date=F("created_at"), + last_karma_gained=F("wallet_user__karma_last_updated_at"), + department=F("user_organization_link_user__department__title"), + graduation_year=F("user_organization_link_user__graduation_year"), + is_alumni=F("user_organization_link_user__is_alumni"), + ) + ) + + filtered_queryset = CommonUtils.get_paginated_queryset( + user_org_links, + request, + ["full_name", "level"], + { + "full_name": "full_name", + "muid": "muid", + "karma": "wallet_user__karma", + "level": "user_lvl_link_user__level__level_order", + # "is_active": "karma_activity_log_user__created_at", + "join_date": "created_at", + "email": "email_", + "mobile": "mobile_", + "is_alumni": "is_alumni", + }, + is_pagination=False, + ) + + serializer = serializers.CampusStudentDetailsSerializer( + filtered_queryset, many=True, context={"ranks": ranks} + ) + return CommonUtils.generate_csv(serializer.data, "Campus Student Details") + + +class WeeklyKarmaAPI(APIView): + authentication_classes = [CustomizePermission] + + # @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Weekly Karma.", + responses={200: serializers.WeeklyKarmaSerializer}, + ) + def get(self, request, org_id=None): + if org_id: + org = Organization.objects.filter( + id=org_id, org_type=OrganizationType.COLLEGE.value + ).first() + if not org: + return CustomResponse( + general_message="College not found" + ).get_failure_response() + else: + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + org = user_org_link.org + + serializer = serializers.WeeklyKarmaSerializer(org, many=False) + return CustomResponse(response=serializer.data).get_success_response() + + +class ChangeStudentTypeAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Partially update Change Student Type.", + responses={200: serializers.ChangeStudentTypeSerializer}, + ) + def patch(self, request, member_id): + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + user_org_link_obj = UserOrganizationLink.objects.filter( + user__id=member_id, + org=user_org_link.org, + org__org_type=OrganizationType.COLLEGE.value, + ).first() + + serializer = serializers.ChangeStudentTypeSerializer( + user_org_link_obj, data=request.data + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Student Type updated successfully" + ).get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + + +class TransferLeadRoleAPI(APIView): + authentication_classes = [CustomizePermission] + @role_required([RoleType.CAMPUS_LEAD.value,RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Create Transfer Lead Role.", + request=serializers.UserRoleLinkSerializer, + responses={200: serializers.UserRoleLinkSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + new_lead_muid = request.data.get("new_lead_muid", None) + if new_lead_muid is None: + return CustomResponse( + general_message="Required data is missing" + ).get_failure_response() + + new_lead = User.objects.filter(muid=new_lead_muid).first() + if new_lead is None: + return CustomResponse( + general_message="Can't find the user" + ).get_failure_response() + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + validate_new_lead = UserOrganizationLink.objects.filter( + user__id=new_lead.id, + org=user_org_link.org, + org__org_type=OrganizationType.COLLEGE.value, + is_alumni=False, + ).first() + if validate_new_lead is None: + return CustomResponse( + general_message="Can't find the user in your college" + ).get_failure_response() + + if user_id == new_lead.id: + return CustomResponse( + general_message="User already has this role." + ).get_failure_response() + + role_id = Role.objects.filter(title=RoleType.CAMPUS_LEAD.value).first() + if role_id is None: + return CustomResponse( + general_message="Can't find the role" + ).get_failure_response() + role_id = role_id.id + + if UserRoleLink.objects.filter(user=new_lead, role_id=role_id).exists(): + return CustomResponse( + general_message="User already has this role." + ).get_failure_response() + + with transaction.atomic(): + UserRoleLink.objects.filter( + user__id=user_id, + role__id=role_id, + ).delete() + + serializer = serializers.UserRoleLinkSerializer( + data={ + "user": new_lead.id, + "role": role_id, + }, + context={"user_id": user_id}, + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Assigned new Campus Lead successfully" + ).get_success_response() + + # This should realistically not happen as validation occurs before, but if it does, + # transaction.atomic() handles the rollback safely + return CustomResponse(message=serializer.errors).get_failure_response() + + +class TransferEnablerRoleAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Create Transfer Enabler Role.", + request=serializers.UserRoleLinkSerializer, + responses={200: serializers.UserRoleLinkSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + new_enabler_muid = request.data.get("new_enabler_muid", None) + if new_enabler_muid is None: + return CustomResponse( + general_message="Required data is missing" + ).get_failure_response() + + new_enabler = User.objects.filter(muid=new_enabler_muid).first() + if new_enabler is None: + return CustomResponse( + general_message="Can't find the user" + ).get_failure_response() + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + validate_new_enabler = UserOrganizationLink.objects.filter( + user__id=new_enabler.id, + org=user_org_link.org, + org__org_type=OrganizationType.COLLEGE.value, + is_alumni=False, + ).first() + + if validate_new_enabler is None: + return CustomResponse( + general_message="Can't find the user in your college" + ).get_failure_response() + + if user_id == new_enabler.id: + return CustomResponse( + general_message="User already has this role." + ).get_failure_response() + + role_id = Role.objects.filter(title=RoleType.LEAD_ENABLER.value).first() + if role_id is None: + return CustomResponse( + general_message="Can't find the role" + ).get_failure_response() + role_id = role_id.id + + if UserRoleLink.objects.filter(user=new_enabler, role_id=role_id).exists(): + return CustomResponse( + general_message="User already has this role." + ).get_failure_response() + + with transaction.atomic(): + current_enabler = UserRoleLink.objects.filter( + user__user_organization_link_user__org=user_org_link.org, + user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + role__id=role_id, + ).first() + if current_enabler: + current_enabler.delete() + + serializer = serializers.UserRoleLinkSerializer( + data={ + "user": new_enabler.id, + "role": role_id, + }, + context={"user_id": user_id}, + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Assigned new Enabler Lead successfully" + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + +class TransferIGRoleAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Transfer I G Role.", + responses={200: serializers.UserRoleLinkSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + ig_list = ( + CampusIGChapter.objects.filter( + org=user_org_link.org, + is_active=True, + ) + .values_list("ig__code", flat=True) + .distinct() + ) + + return CustomResponse(response={"ig_list": ig_list}).get_success_response() + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Create Transfer I G Role.", + request=serializers.UserRoleLinkSerializer, + responses={200: serializers.UserRoleLinkSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + new_ig_muid = request.data.get("new_ig_muid", None) + ig_code = request.data.get("ig_code", None) + + if new_ig_muid is None or ig_code is None: + return CustomResponse( + general_message="Required data is missing" + ).get_failure_response() + + new_ig = User.objects.filter(muid=new_ig_muid).first() + if new_ig is None: + return CustomResponse( + general_message="Can't find the user" + ).get_failure_response() + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + validate_ig = UserOrganizationLink.objects.filter( + user__id=new_ig.id, + org=user_org_link.org, + org__org_type=OrganizationType.COLLEGE.value, + is_alumni=False, + ).first() + if validate_ig is None: + return CustomResponse( + general_message="Can't find the user in your college" + ).get_failure_response() + + if user_id == new_ig.id: + return CustomResponse( + general_message="User already has this role." + ).get_failure_response() + + chapter = CampusIGChapter.objects.filter( + org=user_org_link.org, + ig__code=ig_code + ).first() + + if not chapter: + return CustomResponse( + general_message="Interest Group chapter not found in your campus" + ).get_failure_response() + + if UserRoleLink.objects.filter(user=new_ig, role__title=RoleType.IG_CAMPUS_LEAD_ROLE(ig_code)).exists(): + return CustomResponse( + general_message="User already has this role." + ).get_failure_response() + + with transaction.atomic(): + assign_ig_campus_lead(chapter, new_ig, user_id) + + return CustomResponse( + general_message="Assigned new IG Campus Lead successfully" + ).get_success_response() + +# ...existing code... + +class CampusStudentListAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve a lightweight list of students (full_name, muid, profile_pic) for the authenticated user's college.", + responses={200: serializers.CampusStudentListSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + org = user_org_link.org + + qs = ( + User.objects.filter( + user_organization_link_user__org=org, + user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + ) + .distinct() + .annotate( + full_name=F("full_name"), + muid=F("muid"), + profile_pic=F("profile_pic"), + ) + .order_by("full_name") + ) + + paginated_queryset = CommonUtils.get_paginated_queryset( + qs, + request, + search_fields=["full_name", "muid"], + sort_fields={ + "full_name": "full_name", + "muid": "muid", + }, + ) + + serializer = serializers.CampusStudentListSerializer( + paginated_queryset.get("queryset"), + many=True, + ) + + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class CampusStudentLeaderboardAPI(APIView): + + authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Student Leaderboard.", + responses={200: serializers.CampusLeaderboardSerializer}, + ) + def get(self, request, org_id=None): + + if not org_id: + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + org = user_org_link.org + else: + org = Organization.objects.filter( + id=org_id, + org_type=OrganizationType.COLLEGE.value + ).first() + + if org is None: + return CustomResponse( + general_message="Campus not found" + ).get_failure_response() + + + + # 1. Compute Campus rank BEFORE filters/pagination # + + rank_qs = ( + Wallet.objects.filter( + user__user_organization_link_user__org=org, + user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + ) + .distinct() + .order_by("-karma","-created_at") + .values("user_id") + ) + ranks = {r["user_id"]: i + 1 for i, r in enumerate(rank_qs)} + + + # 2. Base queryset - all students with annotations # + + qs = ( + User.objects.filter( + user_organization_link_user__org=org, + user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + ) + .distinct() + .annotate( + user_id=F("id"), + # existing fields from CampusStudentDetailsAPI + karma=F("wallet_user__karma"), + level=F("user_lvl_link_user__level__name"), + join_date=F("created_at"), + # org join date (fixed from created_at) + last_karma_gained=F("wallet_user__karma_last_updated_at"), + department=F("user_organization_link_user__department__title"), + graduation_year=F("user_organization_link_user__graduation_year"), + is_alumni=F("user_organization_link_user__is_alumni"), + + # new fields not in existing API + ig_count=Count( + "user_ig_link_user", + distinct=True + ), + ) + .order_by("-wallet_user__karma", "-wallet_user__created_at") # ← rank 1 appears on page 1 + ) + + + # 3. Apply optional filters from query params # + + params = request.query_params + + pass_out_year = params.get("pass_out_year") + ig_id = params.get("ig_id") + category = params.get("category") + is_alumni_param = params.get("is_alumni") + search = params.get("search") + + if pass_out_year: + qs = qs.filter( + user_organization_link_user__graduation_year=pass_out_year + ) + if ig_id: + qs = qs.filter(user_ig_link_user__ig_id=ig_id) + + if category: + qs = qs.filter(user_ig_link_user__ig__category=category) + + if is_alumni_param is not None and is_alumni_param != "": + is_alumni_bool = is_alumni_param.lower() == "true" + qs = qs.filter( + user_organization_link_user__is_alumni=is_alumni_bool + ) + if search: + qs = qs.filter( + Q(full_name__icontains=search) | Q(muid__icontains=search) + ) + + + # 4. Paginate using CommonUtils (handles pageIndex, perPage, sortBy) # + + paginated_queryset = CommonUtils.get_paginated_queryset( + qs, + request, + search_fields=["full_name", "muid"], + sort_fields={ + "full_name": "full_name", + "muid": "muid", + "karma": "wallet_user__karma", + "level": "user_lvl_link_user__level__level_order", + "join_date": "created_at", + "graduation_year": "user_organization_link_user__graduation_year", + "is_alumni": "user_organization_link_user__is_alumni", + }, + ) + + # 5. Serialize and return # + + serializer = serializers.CampusLeaderboardSerializer( + paginated_queryset.get("queryset"), + many=True, + context={"ranks": ranks}, + ) + + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class CampusKarmaByClusterAPI(APIView): + + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Karma By Cluster.", + responses={200: inline_serializer( + name="CampusKarmaByClusterResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusKarmaByClusterCategory", + fields={ + "total_karma": s.IntegerField(), + "member_count": s.IntegerField(), + }, + ), + }, + )}, + ) + def get(self, request, org_id=None): + + if not org_id: + return CustomResponse( + general_message="College not found" + ).get_failure_response() + + org = Organization.objects.filter( + id=org_id, + org_type=OrganizationType.COLLEGE.value + ).first() + + if org is None: + return CustomResponse( + general_message="Campus not found" + ).get_failure_response() + + # Subquery: fetch each user's karma once — no JOIN fan-out + wallet_karma_sq = Wallet.objects.filter( + user=OuterRef("pk") + ).values("karma")[:1] + + # Single query — LEFT JOIN via isnull=False removed + # users with NO IG will have category=None → goes to "unclustered" + all_rows = ( + User.objects.filter( + user_organization_link_user__org=org, + user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + ) + .annotate(user_karma=Subquery(wallet_karma_sq)) + .values("id", "user_ig_link_user__ig__category", "user_karma") + .distinct() # 1 row per (user_id, category) — kills IG fan-out + ) + + # Aggregate in Python + category_map = defaultdict(lambda: {"total_karma": 0, "member_count": 0, "seen_users": set()}) + + for row in all_rows: # streams from DB — no list() memory spike + category = row["user_ig_link_user__ig__category"] or "unclustered" + user_id = row["id"] + karma = row["user_karma"] or 0 + + # seen_users guards against edge case where + # a user has NO IG (category=None) but still appears multiple times + # due to multiple org links + if user_id not in category_map[category]["seen_users"]: + category_map[category]["seen_users"].add(user_id) + category_map[category]["total_karma"] += karma + category_map[category]["member_count"] += 1 + + # Build final response — strip seen_users from output + response = { + category: { + "total_karma": data["total_karma"], + "member_count": data["member_count"], + } + for category, data in category_map.items() + } + + return CustomResponse( + response=response + ).get_success_response() + + +class CampusIGChapterAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus I G Chapter.", + responses={200: serializers.CampusIGChapterListSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + chapters = get_campus_ig_chapters(user_org_link.org.id) + serializer = serializers.CampusIGChapterListSerializer(chapters, many=True) + return CustomResponse(response=serializer.data).get_success_response() + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Create Campus I G Chapter.", + request=serializers.CampusIGChapterCreateSerializer, + responses={200: serializers.CampusIGChapterListSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + serializer = serializers.CampusIGChapterCreateSerializer( + data=request.data, + context={"user_id": user_id, "org": user_org_link.org}, + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="IG Chapter created successfully" + ).get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Partially update Campus I G Chapter.", + responses={200: serializers.CampusIGChapterUpdateSerializer}, + ) + def patch(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + chapter = CampusIGChapter.objects.filter( + id=chapter_id, + org=user_org_link.org, + ).first() + if chapter is None: + return CustomResponse( + general_message="IG Chapter not found" + ).get_failure_response() + + serializer = serializers.CampusIGChapterUpdateSerializer( + chapter, + data=request.data, + partial=True, + context={"user_id": user_id}, + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="IG Chapter updated successfully" + ).get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema(tags=['Dashboard - Campus'], description="Delete Campus I G Chapter.", + responses={200: serializers.CampusIGChapterListSerializer}, + ) + def delete(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + chapter = CampusIGChapter.objects.filter( + id=chapter_id, + org=user_org_link.org, + ).first() + if chapter is None: + return CustomResponse( + general_message="IG Chapter not found" + ).get_failure_response() + + if chapter.lead: + role = Role.objects.filter( + title=RoleType.IG_CAMPUS_LEAD_ROLE(chapter.ig.code) + ).first() + if role: + UserRoleLink.objects.filter( + user=chapter.lead, + user__user_organization_link_user__org=user_org_link.org, + user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + role=role, + ).delete() + chapter.lead = None + + chapter.is_active = False + chapter.updated_by_id = user_id + chapter.save() + + return CustomResponse( + general_message="IG Chapter deleted successfully" + ).get_success_response() + + +class CampusSocialLinkAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Update Campus Social Link.", + request=serializers.CampusSocialLinkUpsertSerializer, + responses={200: serializers.CampusSocialLinkUpsertSerializer}, + ) + def put(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + serializer = serializers.CampusSocialLinkUpsertSerializer( + data=request.data, + context={"user_id": user_id, "org": user_org_link.org}, + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Social link saved successfully" + ).get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema(tags=['Dashboard - Campus'], description="Delete Campus Social Link.", + responses={200: serializers.CampusSocialLinkUpsertSerializer}, + ) + def delete(self, request, link_id): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + social_link = CampusSocialLink.objects.filter( + id=link_id, + org=user_org_link.org, + ).first() + if social_link is None: + return CustomResponse( + general_message="Social link not found" + ).get_failure_response() + + social_link.delete() + + return CustomResponse( + general_message="Social link deleted successfully" + ).get_success_response() + + + +class CampusStudentActivityAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Student Activity.", + responses={200: serializers.StudentActivityTimelineSerializer}, + ) + def get(self, request, muid): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse(general_message="User have no organization").get_failure_response() + + org_id = user_org_link.org_id + + # Verify if the student (muid) belongs to this campus + try: + student = User.objects.get(muid=muid) + is_student_in_campus = UserOrganizationLink.objects.filter( + user=student, org_id=org_id + ).exists() + if not is_student_in_campus: + return CustomResponse(general_message="Student not found in this campus").get_failure_response() + except User.DoesNotExist: + return CustomResponse(general_message="Student not found").get_failure_response() + + from db.task import KarmaActivityLog + activity_logs = KarmaActivityLog.objects.filter( + user=student + ).select_related('task', 'task__ig').order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset(activity_logs, request, ["task__title", "task__ig__name"]) + serializer = serializers.StudentActivityTimelineSerializer(paginated_queryset.get('queryset'), many=True) + + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated_queryset.get('pagination') + ) + + +class CampusShowcaseAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Showcase.", + responses={200: serializers.CampusShowcaseSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + org_id = user_org_link.org_id + try: + from db.organization import CollegeShowcase + showcase = CollegeShowcase.objects.get(org_id=org_id) + serializer = serializers.CampusShowcaseSerializer(showcase) + return CustomResponse(response=serializer.data).get_success_response() + except CollegeShowcase.DoesNotExist: + return CustomResponse( + general_message="Showcase not found for this campus" + ).get_failure_response() + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Partially update Campus Showcase.", + request=serializers.CampusShowcaseSerializer, + responses={200: serializers.CampusShowcaseSerializer}, + ) + def patch(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + org_id = user_org_link.org_id + try: + from db.organization import CollegeShowcase + showcase = CollegeShowcase.objects.get(org_id=org_id) + serializer = serializers.CampusShowcaseSerializer(showcase, data=request.data, partial=True, context={'user_id': user_id, 'org_id': org_id}) + except CollegeShowcase.DoesNotExist: + serializer = serializers.CampusShowcaseSerializer(data=request.data, context={'user_id': user_id, 'org_id': org_id}) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Campus showcase updated successfully" + ).get_success_response() + + return CustomResponse( + response=serializer.errors + ).get_failure_response() + + +class AssignCampusMentorAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Assign a student from the campus as a Campus Mentor.", + request=inline_serializer( + name="AssignCampusMentorRequest", + fields={ + "muid": s.CharField(required=True), + } + ), + responses={200: OpenApiResponse(description="Successfully nominated as a Campus Mentor")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + muid = request.data.get("muid") + + if not muid: + return CustomResponse( + general_message="muid is required" + ).get_failure_response() + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User have no organization" + ).get_failure_response() + + org = user_org_link.org + + student = User.objects.filter(muid=muid).first() + if not student: + return CustomResponse( + general_message="Student not found" + ).get_failure_response() + + # Validate student is in the same campus + student_org_link = UserOrganizationLink.objects.filter( + user=student, + org=org, + org__org_type=OrganizationType.COLLEGE.value, + is_alumni=False + ).first() + + if not student_org_link: + return CustomResponse( + general_message="Student is not a member of your campus" + ).get_failure_response() + + from db.user import UserMentor + from utils.utils import DateTimeUtils + + existing_campus_mentor = UserMentor.objects.filter( + user=student, + mentor_tier=UserMentor.MentorTier.CAMPUS_MENTOR, + org=org, + ).first() + if existing_campus_mentor: + return CustomResponse( + general_message="Student is already a Campus Mentor or has a pending request" + ).get_failure_response() + + other_mentor = UserMentor.objects.filter(user=student).first() + if other_mentor: + return CustomResponse( + general_message=f"Student is already a mentor with tier {other_mentor.mentor_tier}" + ).get_failure_response() + + now = DateTimeUtils.get_current_utc_time() + UserMentor.objects.create( + user=student, + mentor_tier=UserMentor.MentorTier.CAMPUS_MENTOR, + status=UserMentor.Status.PENDING, + org=org, + created_by_id=user_id, + updated_by_id=user_id, + created_at=now, + updated_at=now, + ) + + return CustomResponse( + general_message="Student successfully nominated as a Campus Mentor" + ).get_success_response() diff --git a/api/dashboard/campus/dash_campus_helper.py b/api/dashboard/campus/dash_campus_helper.py index 59e894789..eeabdbc5a 100644 --- a/api/dashboard/campus/dash_campus_helper.py +++ b/api/dashboard/campus/dash_campus_helper.py @@ -1,9 +1,183 @@ -from db.organization import UserOrganizationLink, Organization -from utils.types import OrganizationType +import uuid + +from db.organization import UserOrganizationLink +from db.campus import CampusIGChapter +from db.user import Role, UserRoleLink +from utils.types import OrganizationType, RoleType def get_user_college_link(user_id): return UserOrganizationLink.objects.filter( user_id=user_id, org__org_type=OrganizationType.COLLEGE.value - ).first() + ).order_by("-created_at", "-id").first() + + +def is_approved_campus_mentor(user_id, org): + """ + Return True if the user holds an active CAMPUS_MENTOR grant scoped to the given org. + """ + if org is None: + return False + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import has_scope + return has_scope(user_id, MentorScopeGrant.ScopeType.CAMPUS_MENTOR, org.id) + + +def campus_staff_required(view_func): + """ + Decorator that allows access to Campus Leads, Lead Enablers, Enablers, + AND approved Campus Mentors (read-only campus dashboard endpoints). + + Enablers get read-only access to their own campus: this decorator only + guards GET handlers; all mutating endpoints are gated separately by + @role_required([CAMPUS_LEAD, LEAD_ENABLER]). + + Usage: + @campus_staff_required + def get(self, request): ... + """ + from utils.permission import JWTUtils + from utils.response import CustomResponse + + _STAFF_ROLES = { + RoleType.CAMPUS_LEAD.value, + RoleType.LEAD_ENABLER.value, + RoleType.ENABLER.value, + } + + def wrapped(obj, request, *args, **kwargs): + user_id = JWTUtils.fetch_user_id(request) + roles = set(JWTUtils.fetch_role(request)) + + # Fast path: JWT role is Campus Lead or Lead Enabler + if roles & _STAFF_ROLES: + return view_func(obj, request, *args, **kwargs) + + # Slow path: check if the user is an approved Campus Mentor + # for *their* campus (fetched from org link) + user_link = get_user_college_link(user_id) + if user_link and is_approved_campus_mentor(user_id, user_link.org): + return view_func(obj, request, *args, **kwargs) + + return CustomResponse( + general_message="You do not have the required role to access this page." + ).get_failure_response() + + return wrapped + + +def get_campus_context(request): + """ + Standardized tenancy enforcement helper. + Returns (org, error_response). + """ + from utils.permission import JWTUtils + from utils.response import CustomResponse + from rest_framework import status + + user_id = JWTUtils.fetch_user_id(request) + link = get_user_college_link(user_id) + + if not link or not link.org: + return None, CustomResponse( + general_message="User is not linked to a campus", + message={"error_code": "CAMPUS_NOT_FOUND"}, + ).get_failure_response( + status_code=404, + http_status_code=status.HTTP_404_NOT_FOUND, + ) + + return link.org, None + + +def validate_campus_member(user_id, org_id): + """Confirm that a user is an active member of the given campus (not alumni).""" + return UserOrganizationLink.objects.filter( + user_id=user_id, + org_id=org_id, + org__org_type=OrganizationType.COLLEGE.value, + is_alumni=False, + ).exists() + + +def get_campus_ig_chapters(org_id): + """Return active IG chapters for a campus, with related IG and lead pre-fetched.""" + return CampusIGChapter.objects.filter( + org_id=org_id, + is_active=True, + ).select_related("ig", "lead") + + +def assign_ig_campus_lead(chapter, new_lead, acting_user_id): + """ + Assign a new campus-level IG lead for a chapter. + - Removes the old lead's UserRoleLink for "{ig_code} CampusLead" at this campus. + - Creates a new UserRoleLink for the new lead. + - Updates the chapter's lead field. + Mirrors the role-transfer logic in TransferIGRoleAPI.post(). + """ + ig_code = chapter.ig.code + ig_name = chapter.ig.name + + roles_to_ensure = [ + { + "title": ig_name, + "description": f"{ig_name} Interest Group Member", + }, + { + "title": RoleType.IG_CAMPUS_LEAD_ROLE(ig_code), + "description": f"{ig_name} Interest Group Campus Lead", + }, + { + "title": RoleType.IG_LEAD_ROLE(ig_code), + "description": f"{ig_name} Interest Group Lead", + }, + ] + + for role_data in roles_to_ensure: + Role.objects.get_or_create( + title=role_data["title"], + defaults={ + "id": str(uuid.uuid4()), + "description": role_data["description"], + "created_by_id": acting_user_id, + "updated_by_id": acting_user_id, + } + ) + + role = Role.objects.get(title=RoleType.IG_CAMPUS_LEAD_ROLE(ig_code)) + + # Remove existing campus-level IG lead role for this campus + UserRoleLink.objects.filter( + user__user_organization_link_user__org=chapter.org, + user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + role=role, + ).delete() + + # Assign role to new lead + UserRoleLink.objects.create( + id=str(uuid.uuid4()), + user=new_lead, + role=role, + verified=True, + created_by_id=acting_user_id, + ) + + # Update chapter lead + chapter.lead = new_lead + chapter.updated_by_id = acting_user_id + chapter.save() + + return True + + +def get_campus_events_qs(org): + from api.dashboard.events.serializers import get_live_events + from db.events import Event + + base = get_live_events() + return ( + base.filter(scope=Event.Scope.CAMPUS, scope_org=org) + | base.filter(scope=Event.Scope.CAMPUS_IG, scope_org=org) + ).distinct() \ No newline at end of file diff --git a/api/dashboard/campus/dashboard_views.py b/api/dashboard/campus/dashboard_views.py new file mode 100644 index 000000000..3065208ff --- /dev/null +++ b/api/dashboard/campus/dashboard_views.py @@ -0,0 +1,361 @@ +from datetime import timedelta + +from django.db.models import Count, Max, Sum +from django.utils import timezone +from rest_framework import status +from rest_framework.views import APIView + +from api.dashboard.campus.dash_campus_helper import get_user_college_link +from db.learning_circle import CircleMeetingLog, LearningCircle, UserCircleLink +from db.organization import UserOrganizationLink +from db.task import KarmaActivityLog +from db.user import User +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s + + +def _campus_context(request): + user_id = JWTUtils.fetch_user_id(request) + link = get_user_college_link(user_id) + if not link: + return None, CustomResponse( + general_message="User is not linked to a campus", + message={"error_code": "CAMPUS_NOT_FOUND"}, + ).get_failure_response( + status_code=404, + http_status_code=status.HTTP_404_NOT_FOUND, + ) + return link.org, None + + +def _period_start(request, default_days=30): + period = request.query_params.get("period", f"{default_days}d") + days = {"7d": 7, "30d": 30, "60d": 60, "90d": 90}.get(period, default_days) + return timezone.now() - timedelta(days=days) + + +def _member_funnel(org): + registered = UserOrganizationLink.objects.filter(org=org).count() + onboarded = UserOrganizationLink.objects.filter(org=org, verified=True).count() + active = UserOrganizationLink.objects.filter( + org=org, + user__wallet_user__karma_last_updated_at__gte=timezone.now() - timedelta(days=30), + ).count() + level_2_plus = UserOrganizationLink.objects.filter( + org=org, + user__user_lvl_link_user__level__level_order__gte=2, + ).count() + circle_leads = UserCircleLink.objects.filter( + circle__org=org, + lead=True, + accepted=True, + ).values("user_id").distinct().count() + max_value = registered or 0 + + def stage(key, label, count): + return { + "key": key, + "label": label, + "count": count, + "percentage": round((count / max_value) * 100, 2) if max_value else 0, + } + + return { + "max": max_value, + "stages": [ + stage("registered", "Registered", registered), + stage("onboarded", "Onboarded", onboarded), + stage("active", "Active", active), + stage("level_2_plus", "Level 2+", level_2_plus), + stage("circle_lead", "Circle Lead", circle_leads), + ], + } + + +def _circle_health(org, since): + circles = LearningCircle.objects.filter(org=org).select_related("ig") + data = [] + for circle in circles: + meeting_stats = CircleMeetingLog.objects.filter( + circle_id=circle, + meet_time__gte=since, + ).aggregate(count=Count("id"), last=Max("meet_time")) + sessions = meeting_stats["count"] or 0 + status_value = "active" if sessions >= 2 else "slow" if sessions == 1 else "inactive" + data.append({ + "circle_id": str(circle.id), + "circle_name": circle.title, + "ig_id": str(circle.ig_id), + "ig_name": circle.ig.name if circle.ig else None, + "member_count": UserCircleLink.objects.filter(circle=circle, accepted=True).count(), + "sessions_per_month": sessions, + "last_session_at": meeting_stats["last"].isoformat() if meeting_stats["last"] else None, + "status": status_value, + }) + return data + + +def _recent_activity(org, limit): + activities = [] + for circle in LearningCircle.objects.filter(org=org).select_related("created_by").order_by("-created_at")[:limit]: + activities.append({ + "id": str(circle.id), + "type": "circle_created", + "title": f"{circle.title} created", + "description": f"{circle.title} was created", + "created_at": circle.created_at.isoformat() if circle.created_at else None, + "actor": { + "id": str(circle.created_by_id), + "full_name": circle.created_by.full_name if circle.created_by else None, + "muid": circle.created_by.muid if circle.created_by else None, + "profile_pic": circle.created_by.profile_pic if circle.created_by else None, + }, + "metadata": {"circle_id": str(circle.id), "circle_name": circle.title}, + }) + return sorted(activities, key=lambda item: item["created_at"] or "", reverse=True)[:limit] + + +def _campus_stats(org, since): + members = UserOrganizationLink.objects.filter(org=org,verified=True) + active_members = members.filter(user__wallet_user__karma_last_updated_at__gte=since).count() + total_karma = members.aggregate(total=Sum("user__wallet_user__karma")).get("total") or 0 + active_circles = LearningCircle.objects.filter(org=org).count() + period_karma = KarmaActivityLog.objects.filter( + user__user_organization_link_user__org=org, + created_at__gte=since, + ).aggregate(total=Sum("karma")).get("total") or 0 + return [ + {"key": "active_members", "label": "Active members", "value": active_members, "delta": active_members, "delta_type": "increase", "period": "30d"}, + {"key": "total_karma", "label": "Karma", "value": total_karma, "delta": period_karma, "delta_type": "increase", "period": "30d"}, + {"key": "active_circles", "label": "Circles", "value": active_circles, "delta": 0, "delta_type": "neutral", "period": "30d"}, + {"key": "rank", "label": "Campus rank", "value": None, "delta": 0, "delta_type": "neutral", "period": "30d"}, + ] + + +class CampusDashboardSummaryAPIView(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Dashboard Summary.", + responses={200: inline_serializer( + name="CampusDashboardSummaryResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusDashboardSummaryData", + fields={ + "campus": inline_serializer( + name="CampusDashboardSummaryCampus", + fields={ + "org_id": s.CharField(), + "college_name": s.CharField(), + "campus_code": s.CharField(), + "campus_zone": s.CharField(allow_null=True), + }, + ), + "stat_cards": inline_serializer( + name="CampusDashboardStatCard", + fields={ + "key": s.CharField(), + "label": s.CharField(), + "value": s.IntegerField(allow_null=True), + "delta": s.IntegerField(), + "delta_type": s.CharField(), + "period": s.CharField(), + }, + many=True, + ), + "member_funnel": inline_serializer( + name="CampusDashboardMemberFunnel", + fields={ + "max": s.IntegerField(), + "stages": inline_serializer( + name="CampusDashboardFunnelStage", + fields={ + "key": s.CharField(), + "label": s.CharField(), + "count": s.IntegerField(), + "percentage": s.FloatField(), + }, + many=True, + ), + }, + ), + "circle_health": inline_serializer( + name="CampusDashboardCircleHealth", + fields={ + "circle_id": s.CharField(), + "circle_name": s.CharField(), + "ig_id": s.CharField(), + "ig_name": s.CharField(allow_null=True), + "member_count": s.IntegerField(), + "sessions_per_month": s.IntegerField(), + "last_session_at": s.CharField(allow_null=True), + "status": s.CharField(), + }, + many=True, + ), + "recent_activity": inline_serializer( + name="CampusDashboardRecentActivity", + fields={ + "id": s.CharField(), + "type": s.CharField(), + "title": s.CharField(), + "description": s.CharField(), + "created_at": s.CharField(allow_null=True), + "actor": s.DictField(), + "metadata": s.DictField(), + }, + many=True, + ), + }, + ), + }, + )}, + ) + def get(self, request): + org, error = _campus_context(request) + if error: + return error + since = _period_start(request) + return CustomResponse( + general_message="Campus dashboard summary fetched successfully", + response={ + "campus": { + "org_id": str(org.id), + "college_name": org.title, + "campus_code": org.code, + "campus_zone": org.district.zone.name if org.district and org.district.zone else None, + }, + "stat_cards": _campus_stats(org, since), + "member_funnel": _member_funnel(org), + "circle_health": _circle_health(org, since), + "recent_activity": _recent_activity(org, 10), + }, + ).get_success_response() + + +class CampusMemberFunnelAPIView(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Member Funnel.", + responses={200: inline_serializer( + name="CampusMemberFunnelResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusMemberFunnelData", + fields={ + "max": s.IntegerField(), + "stages": inline_serializer( + name="CampusMemberFunnelStage", + fields={ + "key": s.CharField(), + "label": s.CharField(), + "count": s.IntegerField(), + "percentage": s.FloatField(), + }, + many=True, + ), + }, + ), + }, + )}, + ) + def get(self, request): + org, error = _campus_context(request) + if error: + return error + return CustomResponse( + general_message="Campus member funnel fetched successfully", + response=_member_funnel(org), + ).get_success_response() + + +class CampusCircleHealthAPIView(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Circle Health.", + responses={200: inline_serializer( + name="CampusCircleHealthResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusCircleHealthData", + fields={ + "data": inline_serializer( + name="CampusCircleHealthItem", + fields={ + "circle_id": s.CharField(), + "circle_name": s.CharField(), + "ig_id": s.CharField(), + "ig_name": s.CharField(allow_null=True), + "member_count": s.IntegerField(), + "sessions_per_month": s.IntegerField(), + "last_session_at": s.CharField(allow_null=True), + "status": s.CharField(), + }, + many=True, + ), + }, + ), + }, + )}, + ) + def get(self, request): + org, error = _campus_context(request) + if error: + return error + return CustomResponse( + general_message="Campus circle health fetched successfully", + response={"data": _circle_health(org, _period_start(request))}, + ).get_success_response() + + +class CampusRecentActivityAPIView(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Recent Activity.", + responses={200: inline_serializer( + name="CampusRecentActivityResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusRecentActivityData", + fields={ + "data": inline_serializer( + name="CampusRecentActivityItem", + fields={ + "id": s.CharField(), + "type": s.CharField(), + "title": s.CharField(), + "description": s.CharField(), + "created_at": s.CharField(allow_null=True), + "actor": s.DictField(), + "metadata": s.DictField(), + }, + many=True, + ), + }, + ), + }, + )}, + ) + def get(self, request): + org, error = _campus_context(request) + if error: + return error + limit = min(int(request.query_params.get("limit", 10)), 50) + return CustomResponse( + general_message="Campus recent activity fetched successfully", + response={"data": _recent_activity(org, limit)}, + ).get_success_response() diff --git a/api/dashboard/campus/events_views.py b/api/dashboard/campus/events_views.py new file mode 100644 index 000000000..27b3e2633 --- /dev/null +++ b/api/dashboard/campus/events_views.py @@ -0,0 +1,590 @@ +from collections import Counter + +from django.db.models import Q +from rest_framework.views import APIView + +from db.organization import UserOrganizationLink +from db.user import User, Role, UserRoleLink +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import OrganizationType, RoleType +from utils.utils import CommonUtils +from db.campus import CampusIGChapter +from db.task import InterestGroup +import uuid +from . import serializers as campus_serializers +from .dash_campus_helper import ( + get_user_college_link, + get_campus_events_qs, + validate_campus_member, + campus_staff_required, +) +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s + + +class CampusEventsAPI(APIView): + """ + GET campus/events/ + Returns paginated campus-scoped and campus-IG-scoped events + for the authenticated campus lead's campus. + """ + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Events.", + responses={200: campus_serializers.CampusEventListSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User has no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + org = user_org_link.org + events = get_campus_events_qs(org) + + params = request.query_params + + if status := params.get("status"): + events = events.filter(status=status) + + if scope := params.get("scope"): + events = events.filter(scope=scope) + + if event_type := params.get("event_type"): + events = events.filter(organiser_type=event_type) + + if date_from := params.get("date_from"): + events = events.filter(start_datetime__date__gte=date_from) + + if date_to := params.get("date_to"): + events = events.filter(start_datetime__date__lte=date_to) + + paginated = CommonUtils.get_paginated_queryset( + events, + request, + search_fields=["title"], + sort_fields={ + "start_datetime": "start_datetime", + "interest_count": "interest_count", + }, + ) + + serializer = campus_serializers.CampusEventListSerializer( + paginated["queryset"], many=True + ) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated["pagination"], + } + ).get_success_response() + + +class CampusEventDistributionAPI(APIView): + """ + GET campus/events/distribution/ + Returns ranked tag distribution for all campus events. + Aggregates from Event.tags JSONField using Counter. + """ + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Event Distribution.", + responses={200: inline_serializer( + name="CampusEventDistributionResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusEventDistributionData", + fields={ + "data": inline_serializer( + name="CampusEventTagCount", + fields={ + "tag": s.CharField(), + "event_count": s.IntegerField(), + }, + many=True, + ), + }, + ), + }, + )}, + ) + def get(self, request): + + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User has no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + org = user_org_link.org + tags_qs = get_campus_events_qs(org).values_list("tags", flat=True) + + counter = Counter() + for tags in tags_qs: + if tags: # tags is a JSONField, can be null + counter.update(tags) + + data = [ + {"tag": tag, "event_count": count} + for tag, count in counter.most_common() + ] + + return CustomResponse( + response={"data": data} + ).get_success_response() + + +class CampusExecomAPI(APIView): + """ + GET campus/execom/ — list all execom role holders + POST campus/execom/ — appoint a member to a role + DELETE campus/execom// — remove a role link + """ + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Execom.", + responses={200: campus_serializers.ExecomMemberSerializer}, + ) + def get(self, request): + + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User has no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + org = user_org_link.org + + campus_user_ids = UserOrganizationLink.objects.filter( + org=org, + org__org_type=OrganizationType.COLLEGE.value, + ).values_list("user_id", flat=True) + + # Extracted all non-execom system roles to properly support listing dynamic custom/IG roles + BLACKLIST_ROLES = [ + RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.APPRAISER.value, + RoleType.ZONAL_CAMPUS_LEAD.value, RoleType.DISTRICT_CAMPUS_LEAD.value, + RoleType.MENTOR.value, RoleType.COMPANY.value, RoleType.BOT_DEV.value, + RoleType.TECH_TEAM.value, RoleType.CAMPUS_ACTIVATION_TEAM.value, + RoleType.DISCORD_MANAGER.value, RoleType.EX_OFFICIAL.value, + RoleType.INTERN.value, RoleType.PRE_MEMBER.value, RoleType.SUSPEND.value, + RoleType.MULEARNER.value, RoleType.STUDENT.value, RoleType.ASSOCIATE.value, + RoleType.IG_LEAD.value + ] + + execom_links = UserRoleLink.objects.filter( + user_id__in=campus_user_ids + ).exclude( + role__title__in=BLACKLIST_ROLES + ).select_related("user", "role") + + serializer = campus_serializers.ExecomMemberSerializer( + execom_links, many=True + ) + return CustomResponse( + response={"data": serializer.data} + ).get_success_response() + + @role_required([RoleType.CAMPUS_LEAD.value,RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Campus'], + description="Create Campus Execom.", + request=campus_serializers.UserRoleLinkSerializer, + responses={200: campus_serializers.ExecomMemberSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + + muid = request.data.get("muid") + role_title = request.data.get("role_title") + + if not muid or not role_title: + return CustomResponse( + general_message="muid and role_title are required" + ).get_failure_response() + + # System roles that are highly privileged and cannot be assigned by campus leads + BLACKLIST_ROLES = [ + RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.APPRAISER.value, + RoleType.ZONAL_CAMPUS_LEAD.value, RoleType.DISTRICT_CAMPUS_LEAD.value, + RoleType.MENTOR.value, RoleType.COMPANY.value, RoleType.BOT_DEV.value, + RoleType.TECH_TEAM.value, RoleType.CAMPUS_ACTIVATION_TEAM.value, + RoleType.DISCORD_MANAGER.value, RoleType.EX_OFFICIAL.value, + RoleType.INTERN.value, RoleType.PRE_MEMBER.value, RoleType.SUSPEND.value, + RoleType.MULEARNER.value + ] + + if role_title in BLACKLIST_ROLES: + return CustomResponse( + general_message=f"Cannot assign highly privileged system role: {role_title}" + ).get_failure_response() + + # Fetch user by muid + new_user = User.objects.filter(muid=muid).first() + if new_user is None: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + # Get requester's campus + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User has no organization" + ).get_failure_response() + + org = user_org_link.org + if org is None: + return CustomResponse( + general_message="User has no organization" + ).get_failure_response() + + # Validate IG if the role indicates one + active_igs = CampusIGChapter.objects.filter(org=org, is_active=True).select_related("ig") + matched_active_ig = False + matched_inactive_or_missing_ig = False + + all_system_igs = InterestGroup.objects.all() + for ig in all_system_igs: + if role_title.startswith(f"{ig.code} ") or role_title.startswith(f"{ig.name} ") or role_title.startswith(f"{ig.code}_") or role_title.startswith(f"{ig.name}_") or role_title == f"{ig.code} CampusLead" or role_title == f"{ig.code}CampusLead": + ig_active_in_campus = active_igs.filter(ig=ig).exists() + if ig_active_in_campus: + matched_active_ig = True + else: + matched_inactive_or_missing_ig = True + + if matched_inactive_or_missing_ig and not matched_active_ig: + return CustomResponse( + general_message="The Interest Group for this role is not active in your campus." + ).get_failure_response() + + # Validate new user is a non-alumni campus member + if not validate_campus_member(new_user.id, org.id): + return CustomResponse( + general_message="User is not a member of your campus" + ).get_failure_response() + + # Fetch role by title + role = Role.objects.filter(title=role_title).first() + if role is None: + role = Role.objects.create( + id=str(uuid.uuid4()), + title=role_title, + created_by_id=user_id, + updated_by_id=user_id + ) + + # Remove existing holder of this role in the campus + campus_user_ids = UserOrganizationLink.objects.filter( + org=org, + org__org_type=OrganizationType.COLLEGE.value, + ).values_list("user_id", flat=True) + + UserRoleLink.objects.filter( + user_id__in=campus_user_ids, + role=role, + ).delete() + + # Assign new role — follows UserRoleLinkSerializer pattern + serializer = campus_serializers.UserRoleLinkSerializer( + data={"user": new_user.id, "role": role.id}, + context={"user_id": user_id}, + ) + if serializer.is_valid(): + serializer.save() + + if role_title.endswith("CampusLead") and role_title not in [RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]: + ig_code = role_title.replace("CampusLead", "").strip() + chapter = CampusIGChapter.objects.filter(org=org, ig__code=ig_code, is_active=True).first() + if chapter: + chapter.lead = new_user + chapter.updated_by_id = user_id + chapter.save() + + return CustomResponse( + general_message="Role assigned successfully" + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + @role_required([RoleType.CAMPUS_LEAD.value,RoleType.LEAD_ENABLER.value]) + @extend_schema(tags=['Dashboard - Campus'], description="Delete Campus Execom.", + responses={200: campus_serializers.ExecomMemberSerializer}, + ) + + def delete(self, request, member_id=None): + user_id = JWTUtils.fetch_user_id(request) + + if not member_id: + return CustomResponse( + general_message="member_id is required" + ).get_failure_response() + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User has no organization" + ).get_failure_response() + + org = user_org_link.org + + if org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + # Fetch the role link + role_link = UserRoleLink.objects.filter( + id=member_id + ).select_related("role").first() + + if role_link is None: + return CustomResponse( + general_message="Role link not found" + ).get_failure_response() + + # Guard: must belong to this campus + is_campus_member = UserOrganizationLink.objects.filter( + org=org, + org__org_type=OrganizationType.COLLEGE.value, + user_id=role_link.user_id, + ).exists() + + if not is_campus_member: + return CustomResponse( + general_message="Role link not found or not part of this campus" + ).get_failure_response() + + # Guard: campus lead cannot remove their own lead role + if ( + role_link.user_id == user_id + and role_link.role.title == RoleType.CAMPUS_LEAD.value + ): + return CustomResponse( + general_message="Cannot remove your own Campus Lead role. Use transfer-lead-role instead." + ).get_failure_response() + + role_title = role_link.role.title + user_id_of_role = role_link.user_id + role_link.delete() + + if role_title.endswith("CampusLead") and role_title not in [RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]: + ig_code = role_title.replace("CampusLead", "").strip() + chapter = CampusIGChapter.objects.filter(org=org, ig__code=ig_code, is_active=True).first() + if chapter and chapter.lead_id == user_id_of_role: + chapter.lead = None + chapter.updated_by_id = user_id + chapter.save() + + return CustomResponse( + general_message="Role removed successfully" + ).get_success_response() + + +class CampusExecomRoleAPI(APIView): + """ + GET campus/execom/roles/ — list all assignable roles + POST campus/execom/roles/ — explicitly create a custom role + """ + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema(tags=['Dashboard - Campus'], description="Retrieve Campus Execom Role.", + responses={200: inline_serializer( + name="CampusExecomRoleListResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusExecomRoleListData", + fields={ + "data": s.ListField(child=s.CharField()), + }, + ), + }, + )}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse(general_message="User has no organization").get_failure_response() + + org = user_org_link.org + if org is None: + return CustomResponse(general_message="Campus lead has no college").get_failure_response() + + roles = set() + roles.add(RoleType.CAMPUS_LEAD.value) + roles.add(RoleType.LEAD_ENABLER.value) + roles.add(RoleType.ENABLER.value) + roles.add(RoleType.IG_LEAD.value) + + + # Active IG roles for this campus + active_chapters = CampusIGChapter.objects.filter(org=org, is_active=True).select_related("ig") + for chapter in active_chapters: + if chapter.ig: + roles.add(f"{chapter.ig.code} CampusLead") + roles.add(f"{chapter.ig.code} IGLead") + + return CustomResponse(response={"data": sorted(list(roles))}).get_success_response() + + + @role_required([RoleType.CAMPUS_LEAD.value,RoleType.LEAD_ENABLER.value]) + @extend_schema(tags=['Dashboard - Campus'], description="Create Campus Execom Role.", + responses={200: OpenApiResponse(description="Role created or already exists")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + role_title = request.data.get("role_title") + + if not role_title: + return CustomResponse(general_message="role_title is required").get_failure_response() + + blacklist = [ + RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.APPRAISER.value, + RoleType.ZONAL_CAMPUS_LEAD.value, RoleType.DISTRICT_CAMPUS_LEAD.value, + RoleType.MENTOR.value, RoleType.COMPANY.value, RoleType.BOT_DEV.value, + RoleType.TECH_TEAM.value, RoleType.CAMPUS_ACTIVATION_TEAM.value, + RoleType.DISCORD_MANAGER.value, RoleType.EX_OFFICIAL.value, + RoleType.INTERN.value, RoleType.PRE_MEMBER.value, RoleType.SUSPEND.value, + RoleType.MULEARNER.value + ] + + if role_title in blacklist: + return CustomResponse(general_message=f"Cannot create highly privileged system role: {role_title}").get_failure_response() + + role = Role.objects.filter(title=role_title).first() + if role: + return CustomResponse(general_message="Role already exists").get_success_response() + + Role.objects.create( + id=str(uuid.uuid4()), + title=role_title, + created_by_id=user_id, + updated_by_id=user_id + ) + + return CustomResponse(general_message="Role created successfully").get_success_response() + + +class CampusUserSearchAPI(APIView): + """ + GET campus/execom/search/?q= + + Searches campus members by full_name or muid (case-insensitive partial match). + Returns all matching members regardless of whether they already hold an Execom + role — this fixes the inconsistency where some existing Execom members were + missing from search results because a plain exact-muid lookup was used. + """ + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description=( + "Search campus members by name or muid. " + "Returns all matching members including existing Execom role holders." + ), + responses={ + 200: inline_serializer( + name="CampusUserSearchResponse", + fields={ + "hasError": s.BooleanField(), + "statusCode": s.IntegerField(), + "message": s.DictField(), + "response": inline_serializer( + name="CampusUserSearchData", + fields={ + "data": inline_serializer( + name="CampusUserSearchItem", + fields={ + "id": s.CharField(), + "full_name": s.CharField(), + "muid": s.CharField(), + "profile_pic": s.CharField(allow_null=True), + }, + many=True, + ) + }, + ), + }, + ) + }, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + if not (user_org_link := get_user_college_link(user_id)): + return CustomResponse( + general_message="User has no organization" + ).get_failure_response() + + if user_org_link.org is None: + return CustomResponse( + general_message="Campus lead has no college" + ).get_failure_response() + + org = user_org_link.org + query = request.query_params.get("q", "").strip() + + if not query: + return CustomResponse( + general_message="Query parameter 'q' is required" + ).get_failure_response() + + # Fetch all active (non-alumni) member IDs for this campus + campus_user_ids = UserOrganizationLink.objects.filter( + org=org, + org__org_type=OrganizationType.COLLEGE.value, + is_alumni=False, + ).values_list("user_id", flat=True) + + # Search by full_name OR muid — case-insensitive partial match. + # Using User.objects (ActiveUserManager) so suspended users are excluded. + users = User.objects.filter( + id__in=campus_user_ids + ).filter( + Q(full_name__icontains=query) | Q(muid__icontains=query) + ).order_by("full_name")[:20] + + data = [ + { + "id": u.id, + "full_name": u.full_name, + "muid": u.muid, + "profile_pic": u.profile_pic, + } + for u in users + ] + + return CustomResponse(response={"data": data}).get_success_response() diff --git a/api/dashboard/campus/ig_views.py b/api/dashboard/campus/ig_views.py new file mode 100644 index 000000000..cada57f06 --- /dev/null +++ b/api/dashboard/campus/ig_views.py @@ -0,0 +1,206 @@ +import uuid + +from django.utils import timezone +from rest_framework.views import APIView +from rest_framework import status +from utils.response import CustomResponse +from utils.permission import CustomizePermission, JWTUtils +from utils.types import RoleType, OrganizationType +from utils.utils import CommonUtils, DateTimeUtils +from .dash_campus_helper import get_campus_context, campus_staff_required +from api.dashboard.ig import services as ig_services +from . import serializers +from drf_spectacular.utils import extend_schema + +from db.campus import CampusIGChapter +from db.task import UserIgLink +from db.organization import UserOrganizationLink + + +class CampusIGsAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus I Gs.", + responses={200: serializers.CampusIGListSerializer}, + ) + def get(self, request): + org, error = get_campus_context(request) + if error: return error + + qs = ig_services.get_campus_igs(org.id) + paginated = CommonUtils.get_paginated_queryset( + qs, request, + search_fields=['name', 'code'], + sort_fields={'name': 'name', 'member_count': 'campus_member_count'} + ) + serializer = serializers.CampusIGListSerializer(paginated.get('queryset'), many=True) + return CustomResponse( + response={"data": serializer.data, "pagination": paginated.get("pagination")} + ).get_success_response() + +class CampusIGMembersAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus I G Members.", + responses={200: serializers.CampusIGMemberSerializer}, + ) + def get(self, request, ig_id): + org, error = get_campus_context(request) + if error: return error + + qs = ig_services.get_ig_members(ig_id, org.id) + paginated = CommonUtils.get_paginated_queryset( + qs, request, + search_fields=['user__full_name', 'user__muid'], + sort_fields={'full_name': 'user__full_name', 'karma': 'user__wallet_user__karma'} + ) + serializer = serializers.CampusIGMemberSerializer(paginated.get('queryset'), many=True) + return CustomResponse( + response={"data": serializer.data, "pagination": paginated.get("pagination")} + ).get_success_response() + + +class CampusIGChapterJoinAPI(APIView): + """ + POST /api/v1/dashboard/campus/ig-chapters//join/ + + Allows an authenticated user to join a Campus IG Chapter. + The user must have a verified college organization link to the same + campus organization that hosts the chapter. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Campus'], + description=( + "Join a Campus IG Chapter. The authenticated user must be a verified " + "member of the same campus organization that hosts this chapter." + ), + responses={200: None}, + ) + def post(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + + # Step 1: Validate the chapter exists and is active + chapter = CampusIGChapter.objects.filter(id=chapter_id, is_active=True).first() + if not chapter: + return CustomResponse( + general_message="Campus IG Chapter not found or inactive" + ).get_failure_response() + + # Step 2: Validate the user has a verified college org link + user_org_link = UserOrganizationLink.objects.filter( + user_id=user_id, + org__org_type=OrganizationType.COLLEGE.value, + verified=True, + ).first() + if not user_org_link: + return CustomResponse( + general_message="You must be a verified college member to join a Campus IG Chapter" + ).get_failure_response() + + # Step 3: Validate the user belongs to the same campus as the chapter + if str(user_org_link.org_id) != str(chapter.org_id): + return CustomResponse( + general_message="You can only join an IG Chapter from your own campus" + ).get_failure_response() + + # Step 4: Handle existing UserIgLink for this IG + existing_link = UserIgLink.objects.filter( + user_id=user_id, + ig=chapter.ig, + assignment_type=UserIgLink.AssignmentType.LEARNER, + ).first() + + if existing_link: + if existing_link.is_active: + return CustomResponse( + general_message="You are already a member of this Interest Group" + ).get_failure_response() + # Reactivate an existing inactive link + existing_link.is_active = True + existing_link.unassigned_at = None + existing_link.assigned_by_id = user_id + existing_link.save(update_fields=["is_active", "unassigned_at", "assigned_by"]) + else: + # Create a new learner link + UserIgLink.objects.create( + id=uuid.uuid4(), + user_id=user_id, + ig=chapter.ig, + assignment_type=UserIgLink.AssignmentType.LEARNER, + is_active=True, + assigned_by_id=user_id, + created_by_id=user_id, + ) + + return CustomResponse( + general_message="Successfully joined the Campus IG Chapter" + ).get_success_response() + + +class CampusIGChapterLeaveAPI(APIView): + """ + DELETE /api/v1/dashboard/campus/ig-chapters//leave/ + + Allows an authenticated user to leave a Campus IG Chapter. + The user must be a verified member of the same campus organization. + The UserIgLink is deactivated (soft-delete) to preserve history. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Campus'], + description=( + "Leave a Campus IG Chapter. The authenticated user's active learner " + "link to the Interest Group is deactivated. History is preserved." + ), + responses={200: None}, + ) + def delete(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + + # Step 1: Validate the chapter exists + chapter = CampusIGChapter.objects.filter(id=chapter_id).first() + if not chapter: + return CustomResponse( + general_message="Campus IG Chapter not found" + ).get_failure_response() + + # Step 2: Validate the user belongs to the same campus as the chapter + user_org_link = UserOrganizationLink.objects.filter( + user_id=user_id, + org__org_type=OrganizationType.COLLEGE.value, + verified=True, + ).first() + if not user_org_link or str(user_org_link.org_id) != str(chapter.org_id): + return CustomResponse( + general_message="You can only leave an IG Chapter from your own campus" + ).get_failure_response() + + # Step 3: Find the active learner link for this IG + link = UserIgLink.objects.filter( + user_id=user_id, + ig=chapter.ig, + assignment_type=UserIgLink.AssignmentType.LEARNER, + is_active=True, + ).first() + if not link: + return CustomResponse( + general_message="You are not an active member of this Interest Group" + ).get_failure_response() + + # Step 4: Soft-deactivate the link + link.is_active = False + link.unassigned_at = DateTimeUtils.get_current_utc_time() + link.save(update_fields=["is_active", "unassigned_at"]) + + return CustomResponse( + general_message="Successfully left the Campus IG Chapter" + ).get_success_response() diff --git a/api/dashboard/campus/lc_views.py b/api/dashboard/campus/lc_views.py new file mode 100644 index 000000000..dae3d1618 --- /dev/null +++ b/api/dashboard/campus/lc_views.py @@ -0,0 +1,58 @@ +from rest_framework.views import APIView +from rest_framework import status +from utils.response import CustomResponse +from utils.permission import CustomizePermission +from utils.types import RoleType +from utils.utils import CommonUtils +from .dash_campus_helper import get_campus_context, campus_staff_required +from api.dashboard.learningcircle import services as lc_services +from . import serializers +from drf_spectacular.utils import extend_schema + +class CampusLearningCirclesAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus Learning Circles.", + responses={200: serializers.CampusLCListSerializer}, + ) + def get(self, request): + org, error = get_campus_context(request) + if error: return error + + qs = lc_services.get_campus_learning_circles(org.id) + paginated = CommonUtils.get_paginated_queryset( + qs, request, + search_fields=['title'], + sort_fields={'name': 'title', 'member_count': 'member_count'} + ) + serializer = serializers.CampusLCListSerializer(paginated.get('queryset'), many=True) + return CustomResponse( + response={"data": serializer.data, "pagination": paginated.get("pagination")} + ).get_success_response() + +class CampusLCMembersAPI(APIView): + authentication_classes = [CustomizePermission] + + @campus_staff_required + @extend_schema( + tags=['Dashboard - Campus'], + description="Retrieve Campus L C Members.", + responses={200: serializers.CampusLCMemberSerializer}, + ) + def get(self, request, circle_id): + org, error = get_campus_context(request) + if error: return error + + qs = lc_services.get_lc_members(circle_id, org.id) + paginated = CommonUtils.get_paginated_queryset( + qs, request, + search_fields=['user__full_name', 'user__muid'], + sort_fields={'full_name': 'user__full_name', 'karma': 'user__wallet_user__karma'} + ) + serializer = serializers.CampusLCMemberSerializer(paginated.get('queryset'), many=True) + return CustomResponse( + response={"data": serializer.data, "pagination": paginated.get("pagination")} + ).get_success_response() diff --git a/api/dashboard/campus/serializers.py b/api/dashboard/campus/serializers.py index 105085f5c..bf2feabeb 100644 --- a/api/dashboard/campus/serializers.py +++ b/api/dashboard/campus/serializers.py @@ -1,298 +1,772 @@ -import uuid -from datetime import timedelta - -from django.db.models import Sum -from rest_framework import serializers - -from db.organization import Organization, UserOrganizationLink, College -from db.task import KarmaActivityLog -from db.user import User, UserRoleLink -from utils.types import OrganizationType -from utils.types import RoleType -from utils.utils import DateTimeUtils - - -class CampusDetailsPublicSerializer(serializers.ModelSerializer): - college_name = serializers.ReadOnlyField(source="title") - campus_code = serializers.ReadOnlyField(source="code") - campus_zone = serializers.ReadOnlyField(source="district.zone.name") - campus_level = serializers.SerializerMethodField() - total_karma = serializers.SerializerMethodField() - total_members = serializers.SerializerMethodField() - active_members = serializers.SerializerMethodField() - rank = serializers.SerializerMethodField() - - class Meta: - model = Organization - fields = [ - "college_name", - "campus_code", - "campus_zone", - "campus_level", - "total_karma", - "total_members", - "active_members", - "rank", - ] - - def get_campus_level(self, obj): - campus = obj.college_org - if campus: - return campus.level - - return None - - def get_total_members(self, obj): - return obj.user_organization_link_org.count() - - def get_active_members(self, obj): - last_month = DateTimeUtils.get_current_utc_time() - timedelta(weeks=26) - return obj.user_organization_link_org.filter( - verified=True, - user__wallet_user__isnull=False, - user__wallet_user__karma_last_updated_at__gte=last_month, - ).count() - - def get_total_karma(self, obj): - return ( - obj.user_organization_link_org.filter( - org__org_type=OrganizationType.COLLEGE.value, - verified=True, - user__wallet_user__isnull=False, - ).aggregate(total_karma=Sum("user__wallet_user__karma"))["total_karma"] - or 0 - ) - - def get_rank(self, obj): - org_karma_dict = ( - UserOrganizationLink.objects.filter( - org__org_type=OrganizationType.COLLEGE.value - ) - .values("org") - .annotate(total_karma=Sum("user__wallet_user__karma")) - ).order_by("-total_karma", "org__created_at") - - rank_dict = { - data["org"]: data["total_karma"] if data["total_karma"] is not None else 0 - for data in org_karma_dict - } - - sorted_rank_dict = dict( - sorted(rank_dict.items(), key=lambda x: x[1], reverse=True) - ) - - if obj.id in sorted_rank_dict: - keys_list = list(sorted_rank_dict.keys()) - position = keys_list.index(obj.id) - return position + 1 - - -class CampusDetailsSerializer(serializers.ModelSerializer): - college_name = serializers.ReadOnlyField(source="org.title") - campus_code = serializers.ReadOnlyField(source="org.code") - campus_zone = serializers.ReadOnlyField(source="org.district.zone.name") - campus_level = serializers.SerializerMethodField() - total_karma = serializers.SerializerMethodField() - total_members = serializers.SerializerMethodField() - active_members = serializers.SerializerMethodField() - rank = serializers.SerializerMethodField() - - lead = serializers.SerializerMethodField() - - class Meta: - model = UserOrganizationLink - fields = [ - "college_name", - "campus_code", - "campus_zone", - "campus_level", - "total_karma", - "total_members", - "active_members", - "rank", - "lead", - ] - - def get_lead(self, obj): - - campus_lead = User.objects.filter( - user_organization_link_user__org=obj.org, - user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - user_role_link_user__role__title=RoleType.CAMPUS_LEAD.value, - ).first() - if campus_lead: - campus_lead = campus_lead.full_name - - enabler = User.objects.filter( - user_organization_link_user__org=obj.org, - user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, - user_role_link_user__role__title=RoleType.LEAD_ENABLER.value, - ).first() - if enabler: - enabler = enabler.full_name - - return {"campus_lead": campus_lead, "enabler": enabler} - - def get_campus_level(self, obj): - campus = College.objects.filter(org=obj.org).first() - if campus: - return campus.level - - return None - - def get_total_members(self, obj): - return obj.org.user_organization_link_org.count() - - def get_active_members(self, obj): - - last_month = DateTimeUtils.get_current_utc_time() - timedelta( - weeks=26 - ) # 6months - return obj.org.user_organization_link_org.filter( - verified=True, - user__wallet_user__isnull=False, - user__wallet_user__karma_last_updated_at__gte=last_month, - ).count() - - def get_total_karma(self, obj): - return ( - obj.org.user_organization_link_org.filter( - org__org_type=OrganizationType.COLLEGE.value, - verified=True, - user__wallet_user__isnull=False, - ).aggregate(total_karma=Sum("user__wallet_user__karma"))["total_karma"] - or 0 - ) - - def get_rank(self, obj): - org_karma_dict = ( - UserOrganizationLink.objects.filter( - org__org_type=OrganizationType.COLLEGE.value - ) - .values("org") - .annotate(total_karma=Sum("user__wallet_user__karma")) - ).order_by("-total_karma", "org__created_at") - - rank_dict = { - data["org"]: data["total_karma"] if data["total_karma"] is not None else 0 - for data in org_karma_dict - } - - sorted_rank_dict = dict( - sorted(rank_dict.items(), key=lambda x: x[1], reverse=True) - ) - - if obj.org.id in sorted_rank_dict: - keys_list = list(sorted_rank_dict.keys()) - position = keys_list.index(obj.org.id) - return position + 1 - - -class CampusStudentDetailsSerializer(serializers.Serializer): - user_id = serializers.CharField() - full_name = serializers.SerializerMethodField() - muid = serializers.CharField() - karma = serializers.IntegerField() - rank = serializers.SerializerMethodField() - level = serializers.CharField() - # is_active = serializers.CharField() - join_date = serializers.CharField() - last_karma_gained = serializers.CharField() - email = serializers.CharField() - mobile = serializers.CharField() - graduation_year = serializers.CharField() - department = serializers.CharField() - is_alumni = serializers.BooleanField() - - class Meta: - fields = ( - "user_id", - "email", - "mobile", - "full_name", - "karma", - "muid", - "rank", - "level", - "join_date", - "is_alumni", - "last_karma_update_at", - ) - - def get_rank(self, obj): - ranks = self.context.get("ranks") - return ranks.get(obj.id, None) - - def get_full_name(self, obj): - return obj.full_name - - -class WeeklyKarmaSerializer(serializers.ModelSerializer): - college_name = serializers.ReadOnlyField(source="title") - - class Meta: - model = Organization - fields = ["college_name"] - - def to_representation(self, instance): - response = super().to_representation(instance) - - today = DateTimeUtils.get_current_utc_time().date() - date_range = [today - timedelta(days=i) for i in range(7)] - - for date in date_range: - karma_logs = KarmaActivityLog.objects.filter( - user__user_organization_link_user__org=instance, - created_at__date=date, - ).aggregate( - karma=Sum("karma"), - ) - response[str(date)] = karma_logs.get("karma", 0) - - return response - - -class ChangeStudentTypeSerializer(serializers.Serializer): - is_alumni = serializers.BooleanField(default=False) - - class Meta: - model = UserOrganizationLink - fields = ("is_alumni",) - - def update(self, instance, validated_data): - instance.is_alumni = validated_data.get("is_alumni") - instance.save() - - return instance - - -class ListAluminiSerializer(serializers.Serializer): - user_id = serializers.CharField() - full_name = serializers.SerializerMethodField() - muid = serializers.CharField() - karma = serializers.IntegerField() - rank = serializers.SerializerMethodField() - level = serializers.CharField() - join_date = serializers.CharField() - - class Meta: - fields = ("user_id", "full_name", "karma", "muid", "rank", "level", "join_date") - - -class UserRoleLinkSerializer(serializers.ModelSerializer): - - class Meta: - model = UserRoleLink - fields = [ - "user", - "role", - ] - - def create(self, validated_data): - user_id = self.context.get("user_id") - validated_data["created_by_id"] = user_id - validated_data["id"] = uuid.uuid4() - validated_data["verified"] = True - - user_role_link = UserRoleLink.objects.create(**validated_data) - return user_role_link +import uuid +from datetime import timedelta + +from django.db.models import Sum +from django.db import transaction +from rest_framework import serializers + +from db.organization import Organization, UserOrganizationLink, College, CollegeShowcase +from db.task import KarmaActivityLog, InterestGroup, UserIgLink +from db.campus import CampusIGChapter, CampusSocialLink +from db.user import User, UserRoleLink +from utils.types import OrganizationType +from utils.types import RoleType, SocialPlatformType +from utils.utils import DateTimeUtils +from .dash_campus_helper import validate_campus_member, assign_ig_campus_lead +from db.events import Event +from db.learning_circle import LearningCircle, UserCircleLink + +class CampusDetailsPublicSerializer(serializers.ModelSerializer): + org_id = serializers.ReadOnlyField(source="id") + college_name = serializers.ReadOnlyField(source="title") + campus_code = serializers.ReadOnlyField(source="code") + campus_zone = serializers.ReadOnlyField(source="district.zone.name") + campus_level = serializers.SerializerMethodField() + total_karma = serializers.SerializerMethodField() + total_members = serializers.SerializerMethodField() + active_members = serializers.SerializerMethodField() + rank = serializers.SerializerMethodField() + social_links = serializers.SerializerMethodField() + + + + class Meta: + model = Organization + fields = [ + "org_id", + "college_name", + "campus_code", + "campus_zone", + "campus_level", + "total_karma", + "total_members", + "active_members", + "rank", + "social_links", + ] + + def get_campus_level(self, obj): + campus = obj.college_org + if campus: + return campus.level + + return None + + def get_total_members(self, obj): + return obj.user_organization_link_org.values('user').distinct().count() + + def get_active_members(self, obj): + return obj.user_organization_link_org.filter( + user__discord_id__isnull=False, + user__exist_in_guild=True + ).values('user').distinct().count() + + def get_total_karma(self, obj): + from db.task import Wallet + users_in_org = obj.user_organization_link_org.values_list('user', flat=True) + return Wallet.objects.filter( + user__in=users_in_org + ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 + + def get_rank(self, obj): + org_karma_dict = ( + UserOrganizationLink.objects.filter( + org__org_type=OrganizationType.COLLEGE.value + ) + .values("org") + .annotate(total_karma=Sum("user__wallet_user__karma")) + ).order_by("-total_karma", "org__created_at") + + rank_dict = { + data["org"]: data["total_karma"] if data["total_karma"] is not None else 0 + for data in org_karma_dict + } + + sorted_rank_dict = dict( + sorted(rank_dict.items(), key=lambda x: x[1], reverse=True) + ) + + if obj.id in sorted_rank_dict: + keys_list = list(sorted_rank_dict.keys()) + position = keys_list.index(obj.id) + return position + 1 + + def get_social_links(self, obj): + links = CampusSocialLink.objects.filter(org=obj) + return CampusSocialLinkSerializer(links, many=True).data + + + +class CampusListSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = ["id", "title", "code"] + + +class CampusDetailsSerializer(serializers.ModelSerializer): + org_id = serializers.ReadOnlyField(source="org.id") + college_name = serializers.ReadOnlyField(source="org.title") + campus_code = serializers.ReadOnlyField(source="org.code") + campus_zone = serializers.ReadOnlyField(source="org.district.zone.name") + campus_level = serializers.SerializerMethodField() + total_karma = serializers.SerializerMethodField() + total_members = serializers.SerializerMethodField() + active_members = serializers.SerializerMethodField() + rank = serializers.SerializerMethodField() + + lead = serializers.SerializerMethodField() + karma_last_7_days = serializers.SerializerMethodField() + karma_last_30_days = serializers.SerializerMethodField() + active_ig_count = serializers.SerializerMethodField() + social_links = serializers.SerializerMethodField() + + class Meta: + model = UserOrganizationLink + fields = [ + "org_id", + "college_name", + "campus_code", + "campus_zone", + "campus_level", + "total_karma", + "total_members", + "active_members", + "rank", + "lead", + "karma_last_7_days", + "karma_last_30_days", + "active_ig_count", + "social_links", + ] + + def get_lead(self, obj): + + campus_lead = User.objects.filter( + user_organization_link_user__org=obj.org, + user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + user_role_link_user__role__title=RoleType.CAMPUS_LEAD.value, + ).first() + if campus_lead: + campus_lead = campus_lead.full_name + + enabler = User.objects.filter( + user_organization_link_user__org=obj.org, + user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + user_role_link_user__role__title=RoleType.LEAD_ENABLER.value, + ).first() + if enabler: + enabler = enabler.full_name + + return {"campus_lead": campus_lead, "enabler": enabler} + + def get_campus_level(self, obj): + campus = College.objects.filter(org=obj.org).first() + if campus: + return campus.level + + return None + + + + def get_total_members(self, obj): + return obj.org.user_organization_link_org.count() + + def get_active_members(self, obj): + + last_month = DateTimeUtils.get_current_utc_time() - timedelta( + weeks=26 + ) # 6months + return obj.org.user_organization_link_org.filter( + verified=True, + user__wallet_user__isnull=False, + user__wallet_user__karma_last_updated_at__gte=last_month, + ).count() + + def get_total_karma(self, obj): + return ( + obj.org.user_organization_link_org.filter( + org__org_type=OrganizationType.COLLEGE.value, + verified=True, + user__wallet_user__isnull=False, + ).aggregate(total_karma=Sum("user__wallet_user__karma"))["total_karma"] + or 0 + ) + + def get_rank(self, obj): + org_karma_dict = ( + UserOrganizationLink.objects.filter( + org__org_type=OrganizationType.COLLEGE.value + ) + .values("org") + .annotate(total_karma=Sum("user__wallet_user__karma")) + ).order_by("-total_karma", "org__created_at") + + rank_dict = { + data["org"]: data["total_karma"] if data["total_karma"] is not None else 0 + for data in org_karma_dict + } + + sorted_rank_dict = dict( + sorted(rank_dict.items(), key=lambda x: x[1], reverse=True) + ) + + if obj.org.id in sorted_rank_dict: + keys_list = list(sorted_rank_dict.keys()) + position = keys_list.index(obj.org.id) + return position + 1 + def get_karma_last_7_days(self, obj): + seven_days_ago = DateTimeUtils.get_current_utc_time() - timedelta(days=7) + return ( + KarmaActivityLog.objects.filter( + user__user_organization_link_user__org=obj.org, + created_at__gte=seven_days_ago, + ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 + ) + + def get_karma_last_30_days(self, obj): + thirty_days_ago = DateTimeUtils.get_current_utc_time() - timedelta(days=30) + return ( + KarmaActivityLog.objects.filter( + user__user_organization_link_user__org=obj.org, + created_at__gte=thirty_days_ago, + ).aggregate(total_karma=Sum("karma"))["total_karma"] or 0 + ) + def get_active_ig_count(self, obj): + return ( + InterestGroup.objects.filter( + user_ig_link_ig__user__user_organization_link_user__org=obj.org, + user_ig_link_ig__user__user_organization_link_user__verified=True, + status="active", + ) + .distinct() + .count() + ) + + def get_social_links(self, obj): + links = CampusSocialLink.objects.filter(org=obj.org) + return CampusSocialLinkSerializer(links, many=True).data + + +class CampusStudentDetailsSerializer(serializers.Serializer): + user_id = serializers.CharField() + full_name = serializers.SerializerMethodField() + muid = serializers.CharField() + karma = serializers.IntegerField() + rank = serializers.SerializerMethodField() + level = serializers.CharField() + # is_active = serializers.CharField() + join_date = serializers.CharField() + last_karma_gained = serializers.CharField() + email = serializers.CharField() + mobile = serializers.CharField() + graduation_year = serializers.CharField() + department = serializers.CharField() + is_alumni = serializers.BooleanField() + ig_count = serializers.IntegerField(default=0) + lc_count = serializers.IntegerField(default=0) + + class Meta: + fields = ( + "user_id", + "email", + "mobile", + "full_name", + "karma", + "muid", + "rank", + "level", + "join_date", + "is_alumni", + "last_karma_update_at", + "ig_count", + "lc_count" + ) + + def get_rank(self, obj): + ranks = self.context.get("ranks") + if ranks: + return ranks.get(obj.id, None) + return getattr(obj, "rank", None) + + def get_full_name(self, obj): + return obj.full_name + + +class WeeklyKarmaSerializer(serializers.ModelSerializer): + college_name = serializers.ReadOnlyField(source="title") + + class Meta: + model = Organization + fields = ["college_name"] + + def to_representation(self, instance): + response = super().to_representation(instance) + + today = DateTimeUtils.get_current_utc_time().date() + date_range = [today - timedelta(days=i) for i in range(7)] + + for date in date_range: + karma_logs = KarmaActivityLog.objects.filter( + user__user_organization_link_user__org=instance, + created_at__date=date, + ).aggregate( + karma=Sum("karma"), + ) + response[str(date)] = karma_logs.get("karma", 0) + + return response + + +class ChangeStudentTypeSerializer(serializers.Serializer): + is_alumni = serializers.BooleanField(default=False) + + class Meta: + model = UserOrganizationLink + fields = ("is_alumni",) + + def update(self, instance, validated_data): + instance.is_alumni = validated_data.get("is_alumni") + instance.save() + + return instance + + +class ListAluminiSerializer(serializers.Serializer): + user_id = serializers.CharField() + full_name = serializers.SerializerMethodField() + muid = serializers.CharField() + karma = serializers.IntegerField() + rank = serializers.SerializerMethodField() + level = serializers.CharField() + join_date = serializers.CharField() + + class Meta: + fields = ("user_id", "full_name", "karma", "muid", "rank", "level", "join_date") + + +class UserRoleLinkSerializer(serializers.ModelSerializer): + + class Meta: + model = UserRoleLink + fields = [ + "user", + "role", + ] + + def create(self, validated_data): + user_id = self.context.get("user_id") + validated_data["created_by_id"] = user_id + validated_data["id"] = uuid.uuid4() + validated_data["verified"] = True + + user_role_link = UserRoleLink.objects.create(**validated_data) + return user_role_link + + +class CampusEventListSerializer(serializers.ModelSerializer): + tags = serializers.JSONField() + + class Meta: + model = Event + fields = [ + "id", + "title", + "status", + "scope", + "organiser_type", + "start_datetime", + "end_datetime", + "venue_type", + "venue_city", + "interest_count", + "cover_image", + "tags", + ] + + +class ExecomMemberSerializer(serializers.ModelSerializer): + user_id = serializers.CharField(source="user.id") + full_name = serializers.CharField(source="user.full_name") + muid = serializers.CharField(source="user.muid") + profile_pic = serializers.SerializerMethodField() + role_title = serializers.CharField(source="role.title") + ig_name = serializers.SerializerMethodField() + + class Meta: + model = UserRoleLink + fields = [ + "id", + "user_id", + "full_name", + "muid", + "profile_pic", + "role_title", + "ig_name", + ] + + def get_profile_pic(self, obj): + return str(obj.user.profile_pic) if obj.user.profile_pic else None + + def get_ig_name(self, obj): + title = obj.role.title + # IG campus lead roles use canonical format: "{ig_code} CampusLead" + if title not in ( + RoleType.CAMPUS_LEAD.value, + RoleType.LEAD_ENABLER.value, + ) and title.endswith("CampusLead"): + ig_name = title.replace("CampusLead", "").strip() + return ig_name or None + return None + + +class CampusLeaderboardSerializer(CampusStudentDetailsSerializer): + # ── override fields (email & mobile removed — public endpoint) ────────── + email = None + mobile = None + + # ── NEW fields not in CampusStudentDetailsSerializer ──────────────────── + profile_pic = serializers.SerializerMethodField() + ig_count = serializers.IntegerField(default=0) + + class Meta(CampusStudentDetailsSerializer.Meta): + # inherit parent Meta and extend fields + fields = ( + "rank", + "user_id", + "full_name", + "muid", + "profile_pic", + "karma", + "level", + "join_date", + "last_karma_gained", + "graduation_year", + "department", + "is_alumni", + "ig_count", + + ) + + def get_profile_pic(self, obj): + return str(obj.profile_pic) if obj.profile_pic else None + # get_rank and get_full_name are inherited from CampusStudentDetailsSerializer + + +class CampusStudentListSerializer(serializers.Serializer): + full_name = serializers.CharField() + muid = serializers.CharField() + profile_pic = serializers.SerializerMethodField() + + class Meta: + fields = ("full_name", "muid", "profile_pic") + + def get_profile_pic(self, obj): + return str(obj.profile_pic) if getattr(obj, 'profile_pic', None) else None + + + +class CampusIGChapterListSerializer(serializers.ModelSerializer): + ig_id = serializers.ReadOnlyField(source="ig.id") + ig_name = serializers.ReadOnlyField(source="ig.name") + ig_code = serializers.ReadOnlyField(source="ig.code") + ig_icon = serializers.ReadOnlyField(source="ig.icon") + lead_id = serializers.ReadOnlyField(source="lead.id") + lead_name = serializers.SerializerMethodField() + campus_ig_member_count = serializers.SerializerMethodField() + + class Meta: + model = CampusIGChapter + fields = [ + "id", + "ig_id", + "ig_name", + "ig_code", + "ig_icon", + "lead_id", + "lead_name", + "description", + "icon_link", + "is_active", + "campus_ig_member_count", + ] + + def get_lead_name(self, obj): + if obj.lead: + return obj.lead.full_name + return None + + def get_campus_ig_member_count(self, obj): + return UserIgLink.objects.filter( + ig=obj.ig, + user__user_organization_link_user__org=obj.org, + ).count() + + +class CampusIGChapterCreateSerializer(serializers.ModelSerializer): + # Accept muid instead of UUID pk so the frontend can pass e.g. "john-doe@mulearn" + lead = serializers.SlugRelatedField( + slug_field="muid", + queryset=User.objects.all(), + required=False, + allow_null=True, + ) + + class Meta: + model = CampusIGChapter + fields = ["ig", "description", "icon_link", "lead"] + + def validate_ig(self, value): + org = self.context.get("org") + if CampusIGChapter.objects.filter(org=org, ig=value, is_active=True).exists(): + raise serializers.ValidationError("An active IG chapter already exists for this campus and IG.") + return value + + def validate_lead(self, value): + if value is None: + return value + org = self.context.get("org") + if not validate_campus_member(value.id, org.id): + raise serializers.ValidationError("The lead must be a member of this campus.") + return value + + def create(self, validated_data): + user_id = self.context.get("user_id") + org = self.context.get("org") + validated_data["id"] = str(uuid.uuid4()) + validated_data["org"] = org + validated_data["created_by_id"] = user_id + validated_data["updated_by_id"] = user_id + + with transaction.atomic(): + chapter = CampusIGChapter.objects.create(**validated_data) + + if chapter.lead: + # assign_ig_campus_lead handles bootstrapping the roles internally + assign_ig_campus_lead(chapter, chapter.lead, user_id) + else: + # Ensure IG roles exist in the database + ig_name = chapter.ig.name + ig_code = chapter.ig.code + + roles_to_ensure = [ + { + "title": ig_name, + "description": f"{ig_name} Interest Group Member", + }, + { + "title": RoleType.IG_CAMPUS_LEAD_ROLE(ig_code), + "description": f"{ig_name} Interest Group Campus Lead", + }, + { + "title": RoleType.IG_LEAD_ROLE(ig_code), + "description": f"{ig_name} Interest Group Lead", + }, + ] + + from db.user import Role + for role_data in roles_to_ensure: + Role.objects.get_or_create( + title=role_data["title"], + defaults={ + "id": str(uuid.uuid4()), + "description": role_data["description"], + "created_by_id": user_id, + "updated_by_id": user_id, + } + ) + + return chapter + + +class CampusIGChapterUpdateSerializer(serializers.ModelSerializer): + # Accept muid instead of UUID pk so the frontend can pass e.g. "john-doe@mulearn" + lead = serializers.SlugRelatedField( + slug_field="muid", + queryset=User.objects.all(), + required=False, + allow_null=True, + ) + + class Meta: + model = CampusIGChapter + fields = ["description", "icon_link", "lead", "is_active"] + + def validate_lead(self, value): + if value is None: + return value + org = self.instance.org + if not validate_campus_member(value.id, org.id): + raise serializers.ValidationError("The lead must be a member of this campus.") + return value + + def update(self, instance, validated_data): + user_id = self.context.get("user_id") + new_lead = validated_data.get("lead") + + instance.description = validated_data.get("description", instance.description) + instance.icon_link = validated_data.get("icon_link", instance.icon_link) + instance.is_active = validated_data.get("is_active", instance.is_active) + instance.updated_by_id = user_id + + # If lead changed, reassign campus IG lead role + if new_lead and new_lead != instance.lead: + success = assign_ig_campus_lead(instance, new_lead, user_id) + if not success: + raise serializers.ValidationError( + {"lead": "Could not transfer lead: the required IG campus lead role does not exist."} + ) + else: + instance.save() + + return instance + + +class CampusSocialLinkSerializer(serializers.ModelSerializer): + + class Meta: + model = CampusSocialLink + fields = ["id", "platform", "url"] + + +class CampusSocialLinkUpsertSerializer(serializers.Serializer): + platform = serializers.CharField(max_length=30) + url = serializers.URLField(max_length=500) + + def validate_platform(self, value): + if value not in SocialPlatformType.get_all_values(): + raise serializers.ValidationError( + f"Invalid platform. Must be one of: {', '.join(SocialPlatformType.get_all_values())}" + ) + return value + + def create(self, validated_data): + user_id = self.context.get("user_id") + org = self.context.get("org") + + try: + social_link = CampusSocialLink.objects.get( + org=org, + platform=validated_data["platform"], + ) + social_link.url = validated_data["url"] + social_link.updated_by_id = user_id + social_link.save() + except CampusSocialLink.DoesNotExist: + social_link = CampusSocialLink.objects.create( + id=str(uuid.uuid4()), + org=org, + platform=validated_data["platform"], + url=validated_data["url"], + created_by_id=user_id, + updated_by_id=user_id, + ) + return social_link + +class CampusLCListSerializer(serializers.ModelSerializer): + ig_name = serializers.CharField(source="ig.name", default=None) + member_count = serializers.IntegerField(default=0) + meeting_count = serializers.IntegerField(default=0) + last_meeting_time = serializers.DateTimeField(allow_null=True) + + class Meta: + model = LearningCircle + fields = ["id", "name", "ig_name", "member_count", "meeting_count", "last_meeting_time"] + + name = serializers.CharField(source="title") + +class CampusLCMemberSerializer(serializers.ModelSerializer): + user_id = serializers.CharField(source="user.id") + full_name = serializers.CharField(source="user.full_name") + muid = serializers.CharField(source="user.muid") + karma = serializers.SerializerMethodField() + level = serializers.SerializerMethodField() + + class Meta: + model = UserCircleLink + fields = ["user_id", "full_name", "muid", "karma", "level"] + + def get_karma(self, obj): + return getattr(obj.user.wallet_user, 'karma', 0) if hasattr(obj.user, 'wallet_user') else 0 + + def get_level(self, obj): + level_link = obj.user.user_lvl_link_user.first() + return level_link.level.name if level_link else None + +class CampusIGListSerializer(serializers.ModelSerializer): + campus_member_count = serializers.IntegerField(default=0) + + class Meta: + model = InterestGroup + fields = ["id", "name", "code", "campus_member_count"] + +class CampusIGMemberSerializer(serializers.ModelSerializer): + user_id = serializers.CharField(source="user.id") + full_name = serializers.CharField(source="user.full_name") + muid = serializers.CharField(source="user.muid") + karma = serializers.SerializerMethodField() + level = serializers.SerializerMethodField() + + class Meta: + model = UserIgLink + fields = ["user_id", "full_name", "muid", "karma", "level"] + + def get_karma(self, obj): + return getattr(obj.user.wallet_user, 'karma', 0) if hasattr(obj.user, 'wallet_user') else 0 + + def get_level(self, obj): + level_link = obj.user.user_lvl_link_user.first() + return level_link.level.name if level_link else None + + +class StudentActivityTimelineSerializer(serializers.ModelSerializer): + task_name = serializers.CharField(source="task.title", read_only=True) + ig_name = serializers.CharField(source="task.ig.name", read_only=True) + status = serializers.SerializerMethodField() + + class Meta: + model = KarmaActivityLog + fields = ["id", "task_name", "ig_name", "karma", "status", "created_at"] + + def get_status(self, obj): + if obj.appraiser_approved: + return "Approved" + elif obj.appraiser_approved is False: + return "Rejected" + return "Pending" + + +class CampusShowcaseSerializer(serializers.ModelSerializer): + class Meta: + model = CollegeShowcase + fields = [ + "org_id", + "about", + "hero_image", + "highlights", + "gallery", + "testimonials", + "contact_email", + "contact_phone", + "updated_at" + ] + read_only_fields = ["org_id", "updated_at"] + + def create(self, validated_data): + org_id = self.context.get("org_id") + user_id = self.context.get("user_id") + + showcase, created = CollegeShowcase.objects.update_or_create( + org_id=org_id, + defaults={ + **validated_data, + "updated_by_id": user_id, + "created_by_id": user_id if not getattr(self, 'instance', None) else self.instance.created_by_id + } + ) + return showcase + + def update(self, instance, validated_data): + user_id = self.context.get("user_id") + if user_id: + instance.updated_by_id = user_id + return super().update(instance, validated_data) \ No newline at end of file diff --git a/api/dashboard/campus/session_views.py b/api/dashboard/campus/session_views.py new file mode 100644 index 000000000..38cbad312 --- /dev/null +++ b/api/dashboard/campus/session_views.py @@ -0,0 +1,74 @@ +from rest_framework.views import APIView +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.mentor import MentorshipSession +from api.dashboard.mentor import serializers as mentor_serializers +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from .dash_campus_helper import get_user_college_link, is_approved_campus_mentor + +# sessions are Interest-Group-scoped +# only, and campus mentors do not create sessions. (The list endpoint remains for +# viewing any legacy campus sessions.) + + +class CampusSessionListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Campus Sessions'], + description="List campus mentorship sessions. Admins, campus leads, enablers, and approved campus mentors see all statuses; other users see only scheduled sessions.", + parameters=[ + OpenApiParameter("status", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + ], + responses={200: mentor_serializers.SessionListSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + user_org_link = get_user_college_link(user_id) + if not user_org_link: + return CustomResponse( + general_message="You are not associated with any campus." + ).get_failure_response(status_code=404) + + sessions = MentorshipSession.objects.filter( + entity_id=user_org_link.org_id, + session_type=MentorshipSession.SessionType.CAMPUS_SESSION, + is_deleted=False + ) + + roles = JWTUtils.fetch_role(request) + is_elevated = any( + role in roles + for role in [ + RoleType.ADMIN.value, + RoleType.CAMPUS_LEAD.value, + RoleType.LEAD_ENABLER.value, + ] + ) + if not is_elevated: + is_elevated = is_approved_campus_mentor(user_id, user_org_link.org) + + if is_elevated: + status = request.query_params.get("status") + if status: + sessions = sessions.filter(status=status) + else: + # Regular students can only see scheduled sessions + sessions = sessions.filter(status=MentorshipSession.Status.SCHEDULED) + + paginated_queryset = CommonUtils.get_paginated_queryset( + sessions, request, + search_fields=["title", "description"], + sort_fields={"created_at": "created_at", "starts_at": "starts_at"} + ) + + serializer = mentor_serializers.SessionListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() diff --git a/api/dashboard/campus/urls.py b/api/dashboard/campus/urls.py index 73216d680..d609801d5 100644 --- a/api/dashboard/campus/urls.py +++ b/api/dashboard/campus/urls.py @@ -1,66 +1,228 @@ -from django.urls import path - -from . import campus_views - -urlpatterns = [ - path( - "campus-details/", - campus_views.CampusDetailsAPI.as_view(), - name="campus-details", - ), - path( - "student-level/", - campus_views.CampusStudentInEachLevelAPI.as_view(), - name="student-in-each-level", - ), - path( - "student-level//", - campus_views.CampusStudentInEachLevelAPI.as_view(), - name="student-in-each-level-individual", - ), - path( - "student-details/", - campus_views.CampusStudentDetailsAPI.as_view(), - name="student-details", - ), - path( - "student-details/csv/", - campus_views.CampusStudentDetailsCSVAPI.as_view(), - name="student-details-csv", - ), - path( - "weekly-karma/", - campus_views.WeeklyKarmaAPI.as_view(), - name="weekly-karma-insights", - ), - path( - "weekly-karma//", - campus_views.WeeklyKarmaAPI.as_view(), - name="weekly-karma-insights-individual", - ), - path( - "change-student-type//", - campus_views.ChangeStudentTypeAPI.as_view(), - name="change-student-type", - ), - path( - "transfer-lead-role/", - campus_views.TransferLeadRoleAPI.as_view(), - name="transfer-lead-role", - ), - path( - "transfer-enabler-role/", - campus_views.TransferEnablerRoleAPI.as_view(), - name="transfer-enabler-role", - ), - path( - "transfer-ig-role/", - campus_views.TransferIGRoleAPI.as_view(), - name="transfer-lead-role", - ), - path( - "/", - campus_views.CampusDetailsPublicAPI.as_view(), - name="campus-details-individual", - ), -] +from django.urls import path + +from . import campus_views, session_views as campus_session_views +from . import dashboard_views +from . import events_views +from . import ig_views +from . import lc_views +from . import analytics_views + +urlpatterns = [ + path( + "home-summary/", + dashboard_views.CampusDashboardSummaryAPIView.as_view(), + name="campus-home-summary", + ), + path( + "member-funnel/", + dashboard_views.CampusMemberFunnelAPIView.as_view(), + name="campus-member-funnel", + ), + path( + "circle-health/", + dashboard_views.CampusCircleHealthAPIView.as_view(), + name="campus-circle-health", + ), + path( + "recent-activity/", + dashboard_views.CampusRecentActivityAPIView.as_view(), + name="campus-recent-activity", + ), + path( + "campus-list/", + campus_views.CampusListAPI.as_view(), + name="campus-list", + ), + path( + "campus-details/", + campus_views.CampusDetailsAPI.as_view(), + name="campus-details", + ), + path( + "student-level/", + campus_views.CampusStudentInEachLevelAPI.as_view(), + name="student-in-each-level", + ), + path( + "student-level//", + campus_views.CampusStudentInEachLevelAPI.as_view(), + name="student-in-each-level-individual", + ), + path( + "student-details/", + campus_views.CampusStudentDetailsAPI.as_view(), + name="student-details", + ), + path( + "student-details/csv/", + campus_views.CampusStudentDetailsCSVAPI.as_view(), + name="student-details-csv", + ), + path( + "weekly-karma/", + campus_views.WeeklyKarmaAPI.as_view(), + name="weekly-karma-insights", + ), + path( + "weekly-karma//", + campus_views.WeeklyKarmaAPI.as_view(), + name="weekly-karma-insights-individual", + ), + path( + "change-student-type//", + campus_views.ChangeStudentTypeAPI.as_view(), + name="change-student-type", + ), + path( + "transfer-lead-role/", + campus_views.TransferLeadRoleAPI.as_view(), + name="transfer-lead-role", + ), + path( + "transfer-enabler-role/", + campus_views.TransferEnablerRoleAPI.as_view(), + name="transfer-enabler-role", + ), + path( + "transfer-ig-role/", + campus_views.TransferIGRoleAPI.as_view(), + name="transfer-ig-role", + ), + path( + "events/", + events_views.CampusEventsAPI.as_view(), + name="campus-events", + ), + path( + "events/distribution/", + events_views.CampusEventDistributionAPI.as_view(), + name="campus-event-distribution", + ), + path( + "execom/", + events_views.CampusExecomAPI.as_view(), + name="campus-execom", + ), + path( + "execom/roles/", + events_views.CampusExecomRoleAPI.as_view(), + name="campus-execom-roles", + ), + path( + "execom/search/", + events_views.CampusUserSearchAPI.as_view(), + name="campus-execom-search", + ), + path( + "execom//", + events_views.CampusExecomAPI.as_view(), + name="campus-execom-delete", + ), + path( + "ig-chapters/", + campus_views.CampusIGChapterAPI.as_view(), + name="campus-ig-chapters", + ), + path( + "ig-chapters//", + campus_views.CampusIGChapterAPI.as_view(), + name="campus-ig-chapter-detail", + ), + path( + "ig-chapters//join/", + ig_views.CampusIGChapterJoinAPI.as_view(), + name="campus-ig-chapter-join", + ), + path( + "ig-chapters//leave/", + ig_views.CampusIGChapterLeaveAPI.as_view(), + name="campus-ig-chapter-leave", + ), + path( + "social-links/", + campus_views.CampusSocialLinkAPI.as_view(), + name="campus-social-links", + ), + path( + "social-links//", + campus_views.CampusSocialLinkAPI.as_view(), + name="campus-social-link-detail", + ), + + # New Analytics & Dashboard Routes + path( + "student-list/", + campus_views.CampusStudentListAPI.as_view(), + name="campus-student-list", + ), + path( + "students//activity/", + campus_views.CampusStudentActivityAPI.as_view(), + name="campus-student-activity", + ), + path( + "igs/", + ig_views.CampusIGsAPI.as_view(), + name="campus-igs-list", + ), + path( + "igs//members/", + ig_views.CampusIGMembersAPI.as_view(), + name="campus-igs-members", + ), + path( + "learning-circles/", + lc_views.CampusLearningCirclesAPI.as_view(), + name="campus-lc-list", + ), + path( + "learning-circles//members/", + lc_views.CampusLCMembersAPI.as_view(), + name="campus-lc-members", + ), + path( + "analytics/karma-trend/", + analytics_views.CampusKarmaTrendAPI.as_view(), + name="campus-karma-trend", + ), + path( + "analytics/growth/", + analytics_views.CampusGrowthAPI.as_view(), + name="campus-growth", + ), + + path( + "showcase/", + campus_views.CampusShowcaseAPI.as_view(), + name="campus-showcase", + ), + + path( + "assign-mentor/", + campus_views.AssignCampusMentorAPI.as_view(), + name="assign-campus-mentor", + ), + + path( + "sessions/list/", + campus_session_views.CampusSessionListAPI.as_view(), + name="campus-session-list", + ), + + # Catch-all org_id routes MUST be last (they match any single-segment path) + path( + "/", + campus_views.CampusDetailsPublicAPI.as_view(), + name="campus-details-individual", + ), + path( + "/leaderboard/", + campus_views.CampusStudentLeaderboardAPI.as_view(), + name="campus-student-leaderboard", + ), + path( + "/karma-by-cluster/", + campus_views.CampusKarmaByClusterAPI.as_view(), + name="campus-karma-by-cluster", + ), +] diff --git a/api/dashboard/category/category_serializer.py b/api/dashboard/category/category_serializer.py new file mode 100644 index 000000000..2f843d0e8 --- /dev/null +++ b/api/dashboard/category/category_serializer.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from db.task import Category + + +class CategoryListSerializer(serializers.ModelSerializer): + created_by = serializers.CharField(source="created_by.full_name", read_only=True) + updated_by = serializers.CharField(source="updated_by.full_name", read_only=True) + + + class Meta: + model= Category + fields= '__all__' + +class CategoryCUDSerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ["name", "description", "entity_id", "entity_type"] + read_only_fields = ["id", "created_by", "updated_by", "created_at", "updated_at"] + + def create(self, validated_data): + user_id = self.context.get("user_id") + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + return Category.objects.create(**validated_data) + + def update(self, instance, validated_data): + user_id = self.context.get("user_id") + instance.updated_by_id = user_id + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance \ No newline at end of file diff --git a/api/dashboard/category/category_views.py b/api/dashboard/category/category_views.py new file mode 100644 index 000000000..e16bbcb88 --- /dev/null +++ b/api/dashboard/category/category_views.py @@ -0,0 +1,149 @@ +from db.task import Category +from rest_framework.views import APIView +from utils.permission import JWTUtils, CustomizePermission +from utils.response import CustomResponse +from .category_serializer import CategoryListSerializer, CategoryCUDSerializer +from utils.types import RoleType +from utils.permission import role_required +from utils.utils import CommonUtils +from drf_spectacular.utils import extend_schema + +class CategoryAPI(APIView): + authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Category'], + description="Retrieve Category.", + responses={200: CategoryListSerializer}, + ) + def get(self, request, category_id=None): + if category_id: + category = Category.objects.filter(id=category_id).first() + if not category: + return CustomResponse( + general_message='Invalid Category id' + ).get_failure_response() + serializer = CategoryListSerializer(category) + return CustomResponse(response=serializer.data).get_success_response() + + category = Category.objects.all() + paginated_queryset = CommonUtils.get_paginated_queryset( + category, + request, + ['name'] + ) + serializer = CategoryListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated_queryset.get("pagination") + ) + + @role_required( + [ + RoleType.ADMIN.value, + ] + ) + @extend_schema( + tags=['Dashboard - Category'], + description="Create Category.", + request=CategoryCUDSerializer, + responses={200: CategoryListSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + serializer = CategoryCUDSerializer( + data=request.data, + context={'user_id': user_id} + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message='Category created successfully', + response=serializer.data + ).get_success_response() + return CustomResponse( + general_message=serializer.errors, + ).get_failure_response() + + @role_required( + [ + RoleType.ADMIN.value, + ] + ) + @extend_schema( + tags=['Dashboard - Category'], + description="Update Category.", + responses={200: CategoryCUDSerializer}, + ) + def put(self, request, category_id): + user_id = JWTUtils.fetch_user_id(request) + category = Category.objects.filter(id=category_id).first() + if not category: + return CustomResponse( + general_message='Invalid Category id' + ).get_failure_response() + serializer = CategoryCUDSerializer( + category, + data=request.data, + context={'user_id': user_id} + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message='Category updated successfully', + response=serializer.data + ).get_success_response() + return CustomResponse( + general_message=serializer.errors, + ).get_failure_response() + + @role_required( + [ + RoleType.ADMIN.value, + ] + ) + @extend_schema( + tags=['Dashboard - Category'], + description="Partially update Category.", + responses={200: CategoryCUDSerializer}, + ) + def patch(self, request, category_id): + user_id = JWTUtils.fetch_user_id(request) + category = Category.objects.filter(id=category_id).first() + if not category: + return CustomResponse( + general_message='Invalid Category id' + ).get_failure_response() + serializer = CategoryCUDSerializer( + category, + data=request.data, + context={'user_id': user_id}, + partial=True + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message='Category updated successfully', + response=serializer.data + ).get_success_response() + return CustomResponse( + general_message=serializer.errors, + ).get_failure_response() + + @role_required( + [ + RoleType.ADMIN.value, + ] + ) + @extend_schema(tags=['Dashboard - Category'], description="Delete Category.", + responses={200: CategoryListSerializer}, + ) + def delete(self, request, category_id): + category = Category.objects.filter(id=category_id).first() + if not category: + return CustomResponse( + general_message='Invalid Category id' + ).get_failure_response() + category.delete() + return CustomResponse( + general_message='Category deleted successfully' + ).get_success_response() \ No newline at end of file diff --git a/api/dashboard/category/urls.py b/api/dashboard/category/urls.py new file mode 100644 index 000000000..5ed283ee7 --- /dev/null +++ b/api/dashboard/category/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import category_views + +urlpatterns = [ + path('', category_views.CategoryAPI.as_view()), + path('/', category_views.CategoryAPI.as_view()), +] \ No newline at end of file diff --git a/api/dashboard/channels/channel_views.py b/api/dashboard/channels/channel_views.py index 0368d8c5c..135b742ce 100644 --- a/api/dashboard/channels/channel_views.py +++ b/api/dashboard/channels/channel_views.py @@ -7,10 +7,16 @@ from utils.types import RoleType from utils.utils import CommonUtils from .serializers import ChannelCUDSerializer, ChannelListSerializer +from drf_spectacular.utils import extend_schema class ChannelCRUDAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Channels'], + description="Retrieve Channel C R U D.", + responses={200: ChannelListSerializer}, + ) def get(self, request): channel = Channel.objects.all() @@ -33,6 +39,12 @@ def get(self, request): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Channels'], + description="Create Channel C R U D.", + request=ChannelCUDSerializer, + responses={200: ChannelListSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -57,6 +69,11 @@ def post(self, request): ).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Channels'], + description="Update Channel C R U D.", + responses={200: ChannelCUDSerializer}, + ) def put(self, request, channel_id): user_id = JWTUtils.fetch_user_id(request) @@ -89,6 +106,9 @@ def put(self, request, channel_id): ).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Channels'], description="Delete Channel C R U D.", + responses={200: ChannelListSerializer}, + ) def delete(self, request, channel_id): channel = Channel.objects.filter( diff --git a/api/dashboard/college/college_view.py b/api/dashboard/college/college_view.py index 7c529b736..73771ea03 100644 --- a/api/dashboard/college/college_view.py +++ b/api/dashboard/college/college_view.py @@ -7,12 +7,18 @@ from utils.response import CustomResponse from utils.types import OrganizationType from utils.utils import CommonUtils +from drf_spectacular.utils import extend_schema from .serializer import ( CollegeListSerializer, CollegeChangeSerializer, ) class CollegeApi(APIView): + @extend_schema( + tags=['Dashboard - College'], + description="Retrieve College Api.", + responses={200: CollegeListSerializer}, + ) def get(self, request, college_code=None): if college_code: colleges = College.objects.filter(id=college_code) @@ -36,6 +42,12 @@ def get(self, request, college_code=None): class CollegeChangeAPI(APIView): + @extend_schema( + tags=['Dashboard - College'], + description="Partially update College Change.", + request=CollegeChangeSerializer, + responses={200: CollegeChangeSerializer}, + ) def patch(self, request): user_id = JWTUtils.fetch_user_id(request) serializer = CollegeChangeSerializer(data=request.data) diff --git a/api/dashboard/company/__init__.py b/api/dashboard/company/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/dashboard/company/analytics_views.py b/api/dashboard/company/analytics_views.py new file mode 100644 index 000000000..2ca57da8c --- /dev/null +++ b/api/dashboard/company/analytics_views.py @@ -0,0 +1,233 @@ +from rest_framework.views import APIView +from django.db.models import Avg, Count, Sum +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from db.job import CompanyJob, UserJobApplication +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers +from .company_views import _get_company_for_user + +class CompanyGigAnalyticsAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Analytics'], + description="Retrieve analytics data for company gigs (creator or approved company mentor).", + responses={ + 200: inline_serializer( + name='CompanyGigAnalyticsResponse', + fields={ + 'total_gigs_posted': serializers.IntegerField(), + 'active_gigs': serializers.IntegerField(), + 'closed_gigs': serializers.IntegerField(), + 'average_hourly_rate': serializers.FloatField(), + 'application_funnel': serializers.DictField(), + 'conversion_rate': serializers.CharField(), + } + ) + } + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + + if not company: + return CustomResponse(general_message="Company profile not found or access denied.").get_failure_response(status_code=404) + + gigs = CompanyJob.objects.filter(company=company, job_type='Gig', is_deleted=False) + + total_gigs_posted = gigs.count() + active_gigs = gigs.filter(status='Active').count() + closed_gigs = gigs.filter(status='Closed').count() + + avg_hourly_rate = gigs.aggregate(Avg('hourly_rate'))['hourly_rate__avg'] or 0.0 + + applications = UserJobApplication.objects.filter(job__in=gigs) + total_applications = applications.count() + + funnel_data = applications.values('status').annotate(count=Count('status')) + funnel_dict = { + "Total": total_applications, + "Pending": 0, + "In-Review": 0, + "Shortlisted": 0, + "Interview": 0, + "Selected": 0, + "Rejected": 0 + } + + for item in funnel_data: + funnel_dict[item['status']] = item['count'] + + selected_count = funnel_dict["Selected"] + conversion_rate = f"{(selected_count / total_applications * 100):.2f}%" if total_applications > 0 else "0.00%" + + response_data = { + "total_gigs_posted": total_gigs_posted, + "active_gigs": active_gigs, + "closed_gigs": closed_gigs, + "average_hourly_rate": float(f"{avg_hourly_rate:.2f}"), + "application_funnel": funnel_dict, + "conversion_rate": conversion_rate + } + + return CustomResponse(response=response_data).get_success_response() + + +def _period_since(request): + from django.utils import timezone + from datetime import timedelta + period = request.query_params.get("period", "30d") + days = {"7d": 7, "30d": 30, "90d": 90}.get(period) + return timezone.now() - timedelta(days=days) if days else None + + +def _public_learners_qs(request): + from db.user import User + from utils.types import RoleType + qs = User.objects.filter( + suspended_at__isnull=True, + user_settings_user__is_public=True, + ).exclude(user_role_link_user__role__title=RoleType.COMPANY.value) + + if karma_min := request.query_params.get("karma_min"): + qs = qs.filter(wallet_user__karma__gte=int(karma_min)) + if karma_max := request.query_params.get("karma_max"): + qs = qs.filter(wallet_user__karma__lte=int(karma_max)) + if level_order_min := request.query_params.get("level_order_min"): + qs = qs.filter(user_lvl_link_user__level__level_order__gte=int(level_order_min)) + if request.query_params.get("interested_in_work", "").lower() in ("true", "1", "yes"): + qs = qs.filter(interested_in_work=True) + if request.query_params.get("interested_in_gig_work", "").lower() in ("true", "1", "yes"): + qs = qs.filter(interested_in_gig_work=True) + if ig_ids := request.query_params.get("ig_ids"): + qs = qs.filter(user_ig_link_user__ig_id__in=[i.strip() for i in ig_ids.split(",") if i.strip()]) + + return qs.distinct() + + +def _talent_pool_payload(request): + from db.user import User + from db.task import Level, UserIgLink + from django.db.models import Count, Sum + from utils.types import RoleType + + learners = _public_learners_qs(request) + total = learners.count() + level_distribution = [] + for level in Level.objects.order_by("level_order"): + count = learners.filter(user_lvl_link_user__level=level).count() + level_distribution.append({ + "level_id": str(level.id), + "level_name": level.name, + "level_order": level.level_order, + "count": count, + "percentage": round((count / total) * 100, 2) if total else 0, + }) + + top_igs = ( + UserIgLink.objects.filter(user__in=learners, is_active=True) + .values("ig_id", "ig__name") + .annotate( + learner_count=Count("user_id", distinct=True), + total_karma=Sum("user__wallet_user__karma"), + ) + .order_by("-learner_count")[:5] + ) + + return { + "total_learners": total, + "level_distribution": level_distribution, + "top_interest_groups": [ + { + "ig_id": item["ig_id"], + "name": item["ig__name"], + "learner_count": item["learner_count"], + "total_karma": item["total_karma"] or 0, + } + for item in top_igs + ], + } + + +class CompanyDashboardSummaryAPIView(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Analytics'], + description="Retrieve dashboard summary statistics for the active company.", + ) + def get(self, request): + from django.db.models import Sum + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + if not company: + return CustomResponse( + general_message="Company profile not found or access denied." + ).get_failure_response(status_code=404) + + since = _period_since(request) + jobs = CompanyJob.objects.filter(company=company, is_deleted=False) + apps = UserJobApplication.objects.filter(job__company=company) + period_jobs = jobs.filter(created_at__gte=since) if since else jobs + period_apps = apps.filter(applied_at__gte=since) if since else apps + + total_views = jobs.aggregate(total=Sum('total_views'))['total'] or 0 + + quick_stats = { + "jobs_posted": jobs.count(), + "total_views": total_views, + "applications": apps.count(), + "hired": apps.filter(status="Selected").count(), + } + + return CustomResponse( + general_message="Company dashboard summary fetched successfully", + response={ + "company": { + "id": str(company.id), + "name": company.name, + "slug": company.slug, + "status": company.status, + "logo": company.logo, + }, + "quick_stats": quick_stats, + "stat_cards": [ + {"key": "jobs_posted", "label": "Jobs posted", "value": quick_stats["jobs_posted"], "delta": period_jobs.count(), "delta_type": "increase", "period": request.query_params.get("period", "30d")}, + {"key": "total_views", "label": "Total views", "value": quick_stats["total_views"], "delta": total_views, "delta_type": "increase" if total_views > 0 else "neutral", "period": request.query_params.get("period", "30d")}, + {"key": "applications", "label": "Applications", "value": quick_stats["applications"], "delta": period_apps.count(), "delta_type": "increase", "period": request.query_params.get("period", "30d")}, + {"key": "hired", "label": "Hired", "value": quick_stats["hired"], "delta": period_apps.filter(status="Selected").count(), "delta_type": "increase", "period": request.query_params.get("period", "30d")}, + ], + "talent_pool": _talent_pool_payload(request), + }, + ).get_success_response() + + +class CompanyTalentPoolAnalyticsAPIView(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Analytics'], + description="Retrieve analytics data for the talent pool.", + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + if not company: + return CustomResponse( + general_message="Company profile not found or access denied." + ).get_failure_response(status_code=404) + + try: + response = _talent_pool_payload(request) + except ValueError: + return CustomResponse( + general_message="Invalid numeric filter value", + message={"error_code": "INVALID_FILTER_VALUE"}, + ).get_failure_response() + + return CustomResponse( + general_message="Talent pool analytics fetched successfully", + response=response, + ).get_success_response() + diff --git a/api/dashboard/company/company_views.py b/api/dashboard/company/company_views.py new file mode 100644 index 000000000..ca44e4911 --- /dev/null +++ b/api/dashboard/company/company_views.py @@ -0,0 +1,420 @@ +from rest_framework.views import APIView +from django.db.models import Q +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.company import Company +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from . import serializers + +class CompanyRegistrationAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company'], + description="Submit a new company registration.", + request=serializers.CompanyRegisterSerializer, + responses={200: serializers.CompanyRegisterSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + + if Company.objects.filter(company_user_id=user_id).exists(): + return CustomResponse( + general_message="A company request already exists for your account." + ).get_failure_response() + + serializer = serializers.CompanyRegisterSerializer( + data=request.data, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Company registration submitted successfully.", + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + @extend_schema( + tags=['Dashboard - Company'], + description="Update or resubmit a pending/rejected company registration.", + request=serializers.CompanyUpdateSerializer, + responses={200: serializers.CompanyUpdateSerializer}, + ) + def patch(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = Company.objects.filter(company_user_id=user_id).first() + + if not company: + return CustomResponse( + general_message="No company registration request found for your account." + ).get_failure_response(status_code=404) + + if company.status == "verified": + return CustomResponse( + general_message="Your company is already verified. Please use the profile endpoint to update your details." + ).get_failure_response() + + serializer = serializers.CompanyUpdateSerializer( + company, data=request.data, partial=True, context={"user_id": user_id} + ) + + if serializer.is_valid(): + + if company.status == "rejected": + serializer.save(status="pending", rejection_reason=None) + msg = "Company registration updated and resubmitted successfully." + else: + serializer.save() + msg = "Company registration updated successfully." + + return CustomResponse( + general_message=msg, + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class CompanyStatusAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company'], + description="Check the status of a company registration.", + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + company = Company.objects.filter(company_user_id=user_id).first() + if not company: + return CustomResponse( + general_message="No company request found for your account." + ).get_failure_response(status_code=404) + + serializer = serializers.CompanyDetailSerializer(company) + response_data = serializer.data + response_data["company_id"] = company.id + + return CustomResponse( + response=response_data + ).get_success_response() + +class CompanyProfileAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company'], + description="Retrieve the profile of the authenticated company (creator or approved company mentor).", + responses={200: serializers.CompanyDetailSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + + if not company: + return CustomResponse( + general_message="Company profile not found or access denied." + ).get_failure_response(status_code=404) + + serializer = serializers.CompanyDetailSerializer(company) + return CustomResponse(response=serializer.data).get_success_response() + + @extend_schema( + tags=['Dashboard - Company'], + description="Update the profile of the authenticated company (creator or approved company mentor).", + request=serializers.CompanyUpdateSerializer, + responses={200: serializers.CompanyUpdateSerializer}, + ) + def patch(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + + if not company: + return CustomResponse( + general_message="Company profile not found or access denied." + ).get_failure_response(status_code=404) + + serializer = serializers.CompanyUpdateSerializer( + company, data=request.data, partial=True, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Company profile updated successfully.", + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class CompanyListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company'], + description="List all companies with filtering.", + parameters=[ + OpenApiParameter("status", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("industry_sector", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("company_size", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("district_id", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False, description="Filter by district UUID"), + OpenApiParameter("state_id", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False, description="Filter by state UUID"), + OpenApiParameter("country_id", OpenApiTypes.UUID, OpenApiParameter.QUERY, required=False, description="Filter by country UUID"), + ], + responses={200: serializers.CompanyListSerializer(many=True)}, + ) + @role_required([RoleType.ADMIN.value]) + def get(self, request): + companies = Company.objects.all() + + status = request.query_params.get("status") + industry_sector = request.query_params.get("industry_sector") + company_size = request.query_params.get("company_size") + district_id = request.query_params.get("district_id") + state_id = request.query_params.get("state_id") + country_id = request.query_params.get("country_id") + + if status: + companies = companies.filter(status=status) + if industry_sector: + companies = companies.filter(industry_sector=industry_sector) + if company_size: + companies = companies.filter(company_size=company_size) + if district_id: + companies = companies.filter(district_id=district_id) + if state_id: + companies = companies.filter(district__zone__state_id=state_id) + if country_id: + companies = companies.filter(district__zone__state__country_id=country_id) + + paginated_queryset = CommonUtils.get_paginated_queryset( + companies, request, + search_fields=["name", "slug", "email", "industry_sector"], + sort_fields={"name": "name", "status": "status", "created_at": "created_at"} + ) + + serializer = serializers.CompanyListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + +class CompanyDetailAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company'], + description="Get details of a specific company by ID.", + responses={200: serializers.CompanyDetailSerializer}, + ) + @role_required([RoleType.ADMIN.value]) + def get(self, request, company_id): + company = Company.objects.filter(id=company_id).first() + if not company: + return CustomResponse( + general_message="Company not found." + ).get_failure_response(status_code=404) + + serializer = serializers.CompanyDetailSerializer(company) + return CustomResponse(response=serializer.data).get_success_response() + +class CompanyVerifyAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company'], + description="Verify or reject a company.", + request=serializers.CompanyVerifySerializer, + ) + @role_required([RoleType.ADMIN.value]) + def patch(self, request, company_id): + user_id = JWTUtils.fetch_user_id(request) + company = Company.objects.filter(id=company_id).first() + + if not company: + return CustomResponse( + general_message="Company not found." + ).get_failure_response(status_code=404) + + if company.status == "verified": + return CustomResponse( + general_message="Company is already verified." + ).get_failure_response() + + serializer = serializers.CompanyVerifySerializer( + company, data=request.data, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message=f"Company status updated to {serializer.validated_data.get('status')} successfully." + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class PublicCompanyProfileAPI(APIView): + permission_classes = [] + + @extend_schema( + tags=['Public - Company'], + description="Public endpoint to view a company's profile.", + responses={200: serializers.PublicCompanyProfileSerializer}, + ) + def get(self, request, slug): + company = Company.objects.filter(slug=slug, status="verified").first() + if not company: + return CustomResponse( + general_message="Company not found." + ).get_failure_response(status_code=404) + + serializer = serializers.PublicCompanyProfileSerializer(company) + return CustomResponse(response=serializer.data).get_success_response() + + +class CompanyAdminSummaryAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company'], + description="Get summary stats for companies for the admin dashboard.", + ) + @role_required([RoleType.ADMIN.value]) + def get(self, request): + from db.company import Company + from db.job import CompanyJob + from db.task import TaskList + + companies = Company.objects.all() + + data = { + "total_companies": companies.count(), + "verified_companies": companies.filter(status="verified").count(), + "pending_companies": companies.filter(status="pending").count(), + "rejected_companies": companies.filter(status="rejected").count(), + "total_jobs": CompanyJob.objects.count(), + "total_company_tasks": TaskList.objects.filter( + requested_by__user_role_link_user__role__title=RoleType.COMPANY.value + ).count() + } + + return CustomResponse(response=data).get_success_response() + + +# --------------------------------------------------------------------------- +# Shared helper — resolves Company for both creator and approved COMPANY_MENTOR +# --------------------------------------------------------------------------- +def _get_company_for_user(user_id): + """ + Returns the verified Company for a user if they are: + - the company creator (company_user_id == user_id), OR + - hold an active COMPANY_MENTOR grant for that company. + """ + from api.dashboard.mentor.dash_mentor_helper import get_verified_company_for_mentor + return get_verified_company_for_mentor(user_id) + + +# --------------------------------------------------------------------------- +# Company Mentor — Nomination endpoints +# --------------------------------------------------------------------------- + +class CompanyMentorNominateAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Company Mentor"], + description=( + "Nominate a platform user as a Company Mentor for your company. " + "Provide the user's **muid** (e.g. `john-doe@mulearn`). " + "The user must already be a member of the company's organisation " + "(i.e. they appear in `UserOrganizationLink` for this company). " + "Only the verified company creator can nominate. " + "The nomination enters PENDING state until an admin approves it." + ), + request=serializers.CompanyMentorNominateSerializer, + responses={200: serializers.CompanyMentorListSerializer}, + ) + @role_required([RoleType.COMPANY.value]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = Company.objects.filter(company_user_id=user_id, status="verified").first() + + if not company: + return CustomResponse( + general_message="You must have a verified company profile to nominate mentors." + ).get_failure_response(status_code=403) + + serializer = serializers.CompanyMentorNominateSerializer( + data=request.data, + context={"user_id": user_id, "company": company}, + ) + if not serializer.is_valid(): + return CustomResponse(message=serializer.errors).get_failure_response() + + mentor = serializer.save() + + from api.notification.notifications_utils import NotificationUtils + from db.user import User + nominator = User.objects.filter(id=user_id).first() + NotificationUtils.insert_notification( + user=mentor.user, + title=f"Company Mentor nomination: {company.name}"[:50], + description=( + f"{nominator.full_name if nominator else 'Your company'} nominated you as a " + f"Company Mentor for {company.name}. Your application is pending approval." + )[:200], + button='View', + url='/mentor/status/', + created_by=nominator, + ) + + return CustomResponse( + general_message="User nominated as Company Mentor. Pending approval.", + response=serializers.CompanyMentorListSerializer(mentor).data, + ).get_success_response() + + +class CompanyMentorListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Company Mentor"], + description="List all Company Mentor nominations for the authenticated company.", + responses={200: serializers.CompanyMentorListSerializer(many=True)}, + ) + @role_required([RoleType.COMPANY.value]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = Company.objects.filter(company_user_id=user_id, status="verified").first() + + if not company: + return CustomResponse( + general_message="Verified company profile not found." + ).get_failure_response(status_code=404) + + from db.organization import Organization + from utils.types import OrganizationType + org = Organization.objects.filter( + title=company.name, + org_type=OrganizationType.COMPANY.value, + ).first() + + if not org: + return CustomResponse( + general_message="Company organization record not found." + ).get_failure_response(status_code=404) + + from db.user import UserMentor + mentors = UserMentor.objects.filter( + mentor_tier=UserMentor.MentorTier.COMPANY_MENTOR, + org=org, + ).select_related("user").order_by("-created_at") + + serializer = serializers.CompanyMentorListSerializer(mentors, many=True) + return CustomResponse(response=serializer.data).get_success_response() + diff --git a/api/dashboard/company/job_serializers.py b/api/dashboard/company/job_serializers.py new file mode 100644 index 000000000..6c7ed5ef8 --- /dev/null +++ b/api/dashboard/company/job_serializers.py @@ -0,0 +1,233 @@ +import uuid +from rest_framework import serializers +from db.job import CompanyJob, CompanyJobRule, UserJobApplication +from db.company import Company +from db.user import User +from db.task import Wallet, UserLvlLink, UserIgLink +from utils.utils import DateTimeUtils + +class JobRuleSerializer(serializers.ModelSerializer): + class Meta: + model = CompanyJobRule + fields = ['id', 'rule_type', 'rule_value'] + read_only_fields = ['id'] + +class JobCreateSerializer(serializers.ModelSerializer): + rules = JobRuleSerializer(many=True, required=False) + + class Meta: + model = CompanyJob + fields = [ + 'id', 'title', 'experience', 'job_description', 'location', + 'salary_range', 'job_type', 'duration_value', + 'duration_unit', 'hourly_rate', 'deliverables', 'stipend', + 'certificate_provided', 'rules' + ] + read_only_fields = ['id'] + + def validate(self, data): + """Enforce that duration_value and duration_unit are always provided together.""" + duration_value = data.get('duration_value') + duration_unit = data.get('duration_unit') + if duration_value is not None and not duration_unit: + raise serializers.ValidationError( + {"duration_unit": "duration_unit is required when duration_value is provided."} + ) + if duration_unit and duration_value is None: + raise serializers.ValidationError( + {"duration_value": "duration_value is required when duration_unit is provided."} + ) + return data + + def create(self, validated_data): + rules_data = validated_data.pop('rules', []) + user_id = self.context.get('user_id') + + company = self.context.get('company') + if not company: + raise serializers.ValidationError("You do not have a verified company profile or lack permissions.") + + # Jobs are always created as Draft; status can only be changed via PATCH. + validated_data['status'] = 'Draft' + + job = CompanyJob.objects.create(company=company, **validated_data) + + for rule_data in rules_data: + CompanyJobRule.objects.create(job=job, **rule_data) + + return job + +class JobUpdateSerializer(serializers.ModelSerializer): + rules = JobRuleSerializer(many=True, required=False) + + class Meta: + model = CompanyJob + fields = [ + 'title', 'experience', 'job_description', 'location', + 'salary_range', 'job_type', 'status', 'duration_value', + 'duration_unit', 'hourly_rate', 'deliverables', 'stipend', + 'certificate_provided', 'rules' + ] + + def validate(self, data): + """Enforce that duration_value and duration_unit are always provided together.""" + duration_value = data.get('duration_value') + duration_unit = data.get('duration_unit') + if duration_value is not None and not duration_unit: + raise serializers.ValidationError( + {"duration_unit": "duration_unit is required when duration_value is provided."} + ) + if duration_unit and duration_value is None: + raise serializers.ValidationError( + {"duration_value": "duration_value is required when duration_unit is provided."} + ) + return data + + def update(self, instance, validated_data): + rules_data = validated_data.pop('rules', None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.updated_at = DateTimeUtils.get_current_utc_time() + instance.updated_by = self.context.get('user_id') + instance.save() + + if rules_data is not None: + # Non-destructive update: match by rule_type, update value; delete stale; create new + existing_rules = {r.rule_type: r for r in CompanyJobRule.objects.filter(job=instance)} + incoming_types = set() + for rule_data in rules_data: + rule_type = rule_data['rule_type'] + incoming_types.add(rule_type) + if rule_type in existing_rules: + existing_rules[rule_type].rule_value = rule_data['rule_value'] + existing_rules[rule_type].save(update_fields=['rule_value']) + else: + CompanyJobRule.objects.create(job=instance, **rule_data) + # Delete rules that were removed + stale_types = set(existing_rules.keys()) - incoming_types + CompanyJobRule.objects.filter(job=instance, rule_type__in=stale_types).delete() + + return instance + +class JobListSerializer(serializers.ModelSerializer): + company_name = serializers.CharField(source='company.name', read_only=True) + company_logo = serializers.CharField(source='company.logo', read_only=True) + rules = JobRuleSerializer(many=True, read_only=True) + + class Meta: + model = CompanyJob + fields = [ + 'id', 'company_name', 'company_logo', 'title', 'experience', + 'job_description', 'location', 'salary_range', 'job_type', + 'status', 'duration_value', 'duration_unit', 'hourly_rate', + 'deliverables', 'stipend', 'certificate_provided', 'rules', + 'created_at' + ] + +class JobApplicationSerializer(serializers.ModelSerializer): + class Meta: + model = UserJobApplication + fields = ['id', 'job', 'resume_link', 'cover_letter', 'status'] + read_only_fields = ['id', 'status'] + extra_kwargs = { + 'resume_link': {'allow_blank': False}, + } + + def validate(self, data): + user_id = self.context.get('user_id') + job = data.get('job') + + if UserJobApplication.objects.filter(user_id=user_id, job=job).exists(): + raise serializers.ValidationError("You have already applied for this job.") + + user = User.objects.filter(id=user_id).first() + wallet = Wallet.objects.filter(user_id=user_id).first() + user_karma = wallet.karma if wallet else 0 + lvl_link = UserLvlLink.objects.filter(user_id=user_id).first() + user_level = lvl_link.level.level_order if (lvl_link and lvl_link.level) else 0 + rules = CompanyJobRule.objects.filter(job=job) + + for rule in rules: + if rule.rule_type == 'min_karma': + if user_karma < int(rule.rule_value): + raise serializers.ValidationError(f"Insufficient Karma. Minimum {rule.rule_value} required.") + elif rule.rule_type == 'max_karma': + if user_karma > int(rule.rule_value): + raise serializers.ValidationError(f"Exceeds Karma limit. Maximum {rule.rule_value} allowed.") + elif rule.rule_type == 'min_level': + if user_level < int(rule.rule_value): + raise serializers.ValidationError(f"Insufficient Level. Minimum Level {rule.rule_value} required.") + elif rule.rule_type == 'max_level': + if user_level > int(rule.rule_value): + raise serializers.ValidationError(f"Exceeds Level limit. Maximum Level {rule.rule_value} allowed.") + + return data + + def create(self, validated_data): + user_id = self.context.get('user_id') + validated_data['user_id'] = user_id + return UserJobApplication.objects.create(**validated_data) + +class ApplicationTrackingSerializer(serializers.ModelSerializer): + applicant_name = serializers.CharField(source='user.full_name', read_only=True) + applicant_email = serializers.CharField(source='user.email', read_only=True) + + class Meta: + model = UserJobApplication + fields = [ + 'id', 'job', 'applicant_name', 'applicant_email', + 'resume_link', 'cover_letter', 'status', 'rejection_reason', + 'applied_at' + ] + read_only_fields = ['id', 'job', 'applicant_name', 'applicant_email', 'resume_link', 'cover_letter', 'applied_at'] + + def validate(self, data): + """Require rejection_reason when setting status to Rejected. + Prevent any status change once the application is Selected.""" + # Once selected, status cannot be changed + if self.instance and self.instance.status == 'Selected' and 'status' in data: + raise serializers.ValidationError( + {"status": "Cannot change the status of a selected application."} + ) + + status = data.get('status') + rejection_reason = data.get('rejection_reason', '').strip() if data.get('rejection_reason') else '' + if status == 'Rejected' and not rejection_reason: + raise serializers.ValidationError( + {"rejection_reason": "A rejection reason is required when rejecting an application."} + ) + return data + + def update(self, instance, validated_data): + instance.status = validated_data.get('status', instance.status) + instance.rejection_reason = validated_data.get('rejection_reason', instance.rejection_reason) + instance.updated_at = DateTimeUtils.get_current_utc_time() + instance.save() + return instance + +class UserApplicationResubmitSerializer(serializers.ModelSerializer): + class Meta: + model = UserJobApplication + fields = ['resume_link', 'cover_letter'] + + def update(self, instance, validated_data): + instance.resume_link = validated_data.get('resume_link', instance.resume_link) + instance.cover_letter = validated_data.get('cover_letter', instance.cover_letter) + instance.status = 'Pending' + instance.rejection_reason = None + instance.updated_at = DateTimeUtils.get_current_utc_time() + instance.save() + return instance + +class UserAppliedJobsSerializer(serializers.ModelSerializer): + job = JobListSerializer(read_only=True) + + class Meta: + model = UserJobApplication + fields = [ + 'id', 'job', 'resume_link', 'cover_letter', 'status', + 'rejection_reason', 'applied_at' + ] + diff --git a/api/dashboard/company/job_views.py b/api/dashboard/company/job_views.py new file mode 100644 index 000000000..0d1172b71 --- /dev/null +++ b/api/dashboard/company/job_views.py @@ -0,0 +1,439 @@ +from utils.utils import DateTimeUtils +from rest_framework.views import APIView +from django.db.models import Q, F +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.job import CompanyJob, UserJobApplication +from db.company import Company +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from . import job_serializers +from .company_views import _get_company_for_user + +class CompanyJobAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description=( + "Post a new job/gig. " + "Jobs are always created with status='Draft' regardless of any status value sent in the request body. " + "Use PATCH /jobs/{job_id}/ to change the status to 'Active' (or any other value) after creation." + ), + request=job_serializers.JobCreateSerializer, + responses={200: job_serializers.JobCreateSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + if not company: + return CustomResponse( + general_message="Verified company profile not found or access denied." + ).get_failure_response(status_code=403) + + serializer = job_serializers.JobCreateSerializer( + data=request.data, context={"user_id": user_id, "company": company} + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Job posted successfully.", + response=serializer.data + ).get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="List all jobs for the authenticated company (creator or company mentor).", + responses={200: job_serializers.JobListSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + if not company: + return CustomResponse(general_message="Company profile not found or access denied.").get_failure_response(status_code=404) + + jobs = CompanyJob.objects.filter(company=company, is_deleted=False) + paginated_queryset = CommonUtils.get_paginated_queryset( + jobs, request, + search_fields=["title", "location", "job_type"], + sort_fields={"title": "title", "created_at": "created_at"} + ) + serializer = job_serializers.JobListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class CompanyJobDetailAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Retrieve details of a specific job.", + responses={200: job_serializers.JobListSerializer}, + ) + def get(self, request, job_id): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + job = CompanyJob.objects.filter(id=job_id, company=company, is_deleted=False).first() + if not job: + return CustomResponse(general_message="Job not found or access denied.").get_failure_response(status_code=404) + serializer = job_serializers.JobListSerializer(job) + return CustomResponse(response=serializer.data).get_success_response() + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Update a specific job.", + request=job_serializers.JobUpdateSerializer, + responses={200: job_serializers.JobUpdateSerializer}, + ) + def patch(self, request, job_id): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + job = CompanyJob.objects.filter(id=job_id, company=company, is_deleted=False).first() + if not job: + return CustomResponse(general_message="Job not found or access denied.").get_failure_response(status_code=404) + serializer = job_serializers.JobUpdateSerializer(job, data=request.data, partial=True, context={'user_id': user_id}) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Job updated successfully.", + response=serializer.data + ).get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Delete a specific job.", + ) + def delete(self, request, job_id): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + job = CompanyJob.objects.filter(id=job_id, company=company, is_deleted=False).first() + if not job: + return CustomResponse(general_message="Job not found or access denied.").get_failure_response(status_code=404) + job.is_deleted = True + job.updated_at = DateTimeUtils.get_current_utc_time() + job.updated_by = user_id + job.save() + return CustomResponse(general_message="Job deleted successfully.").get_success_response() + +class PublicJobAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Public - Jobs'], + description="Public endpoint to list all active jobs.", + responses={200: job_serializers.JobListSerializer(many=True)}, + ) + def get(self, request): + jobs = CompanyJob.objects.filter(status='Active', is_deleted=False) + + paginated_queryset = CommonUtils.get_paginated_queryset( + jobs, request, + search_fields=["title", "location", "job_type", "company__name"], + sort_fields={"title": "title", "created_at": "created_at"} + ) + + serializer = job_serializers.JobListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class JobApplicationAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Apply to a job.", + request=job_serializers.JobApplicationSerializer, + ) + def post(self, request, job_id): + user_id = JWTUtils.fetch_user_id(request) + + job = CompanyJob.objects.filter(id=job_id, status='Active', is_deleted=False).first() + if not job: + return CustomResponse(general_message="Active job not found.").get_failure_response(status_code=404) + + data = request.data.copy() + data['job'] = job.id + + serializer = job_serializers.JobApplicationSerializer( + data=data, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Application submitted successfully.", + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="List all applications for a specific job (creator or company mentor).", + responses={200: job_serializers.ApplicationTrackingSerializer(many=True)}, + ) + def get(self, request, job_id): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + job = CompanyJob.objects.filter(id=job_id, company=company).first() + if not job: + return CustomResponse(general_message="Job not found or access denied.").get_failure_response(status_code=404) + applications = UserJobApplication.objects.filter(job=job) + paginated_queryset = CommonUtils.get_paginated_queryset( + applications, request, + search_fields=["user__full_name", "status"], + sort_fields={"applied_at": "applied_at", "status": "status"} + ) + serializer = job_serializers.ApplicationTrackingSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + +class ApplicationStatusAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Update the status of a job application (creator or company mentor).", + request=job_serializers.ApplicationTrackingSerializer, + responses={200: job_serializers.ApplicationTrackingSerializer}, + ) + def patch(self, request, app_id): + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + application = UserJobApplication.objects.filter(id=app_id, job__company=company).first() + if not application: + return CustomResponse(general_message="Application not found or access denied.").get_failure_response(status_code=404) + serializer = job_serializers.ApplicationTrackingSerializer( + application, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Application status updated successfully.", + response=serializer.data + ).get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + +class PublicCompanyJobListAPI(APIView): + permission_classes = [] + + @extend_schema( + tags=['Public - Company'], + description="Public endpoint to view all active jobs for a specific company.", + responses={200: job_serializers.JobListSerializer(many=True)}, + ) + def get(self, request, slug): + company = Company.objects.filter(slug=slug, status="verified").first() + if not company: + return CustomResponse( + general_message="Company not found." + ).get_failure_response(status_code=404) + + jobs = CompanyJob.objects.filter(company=company, status='Active', is_deleted=False) + + paginated_queryset = CommonUtils.get_paginated_queryset( + jobs, request, + search_fields=["title", "location", "job_type"], + sort_fields={"title": "title", "created_at": "created_at"} + ) + + serializer = job_serializers.JobListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + +class UserApplicationWithdrawAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Withdraw a submitted job application.", + ) + def delete(self, request, app_id): + user_id = JWTUtils.fetch_user_id(request) + + application = UserJobApplication.objects.filter(id=app_id, user_id=user_id).first() + if not application: + return CustomResponse( + general_message="Application not found or you do not have permission to withdraw it." + ).get_failure_response(status_code=404) + + application.delete() + + return CustomResponse( + general_message="Application withdrawn successfully." + ).get_success_response() + +class UserApplicationResubmitAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Resubmit a rejected job application.", + request=job_serializers.UserApplicationResubmitSerializer, + ) + def patch(self, request, app_id): + user_id = JWTUtils.fetch_user_id(request) + + application = UserJobApplication.objects.filter(id=app_id, user_id=user_id).first() + if not application: + return CustomResponse( + general_message="Application not found or access denied." + ).get_failure_response(status_code=404) + + if application.status != 'Rejected': + return CustomResponse( + general_message="Only rejected applications can be resubmitted." + ).get_failure_response() + + serializer = job_serializers.UserApplicationResubmitSerializer( + application, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Application resubmitted successfully.", + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class UserAppliedJobsAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="List all jobs the user has applied to.", + responses={200: job_serializers.UserAppliedJobsSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + applications = UserJobApplication.objects.filter(user_id=user_id, job__is_deleted=False) + + paginated_queryset = CommonUtils.get_paginated_queryset( + applications, request, + search_fields=["job__title", "job__company__name", "status"], + sort_fields={"applied_at": "applied_at", "status": "status"} + ) + + serializer = job_serializers.UserAppliedJobsSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class TrackJobViewAPIView(APIView): + """ + POST /company/jobs//view/ + + Increments the view count for a specific job listing. + """ + permission_classes = [] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Increment the view count for a specific job listing.", + ) + def post(self, request, job_id): + try: + # Get the job + job = CompanyJob.objects.filter(id=job_id, status='Active', is_deleted=False).first() + if not job: + return CustomResponse( + general_message="Job not found or access denied.", + message={"error_code": "JOB_NOT_FOUND"} + ).get_failure_response(status_code=404) + + # Increment views + job.total_views = F('total_views') + 1 + job.save(update_fields=['total_views']) + + return CustomResponse( + general_message="Job view tracked successfully.", + response={} + ).get_success_response() + + except Exception as e: + return CustomResponse( + general_message="Something went wrong", + message={"error_code": "SERVER_ERROR"} + ).get_failure_response(status_code=500) + + +class CompanyJobEngagementAnalyticsAPIView(APIView): + """ + GET /company/jobs//analytics/ + + Fetches detailed view, application, and hired statistics for a specific job posting. + """ + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company Jobs'], + description="Fetches detailed view, application, and hired statistics for a specific job posting.", + ) + def get(self, request, job_id): + try: + user_id = JWTUtils.fetch_user_id(request) + company = _get_company_for_user(user_id) + if not company: + return CustomResponse( + general_message="Company profile not found or access denied." + ).get_failure_response(status_code=404) + + # Get the job + job = CompanyJob.objects.filter(id=job_id, company=company, is_deleted=False).first() + if not job: + return CustomResponse( + general_message="Job not found or access denied.", + message={"error_code": "JOB_NOT_FOUND"} + ).get_failure_response(status_code=404) + + # Aggregate metrics + total_views = job.total_views + total_applications = UserJobApplication.objects.filter(job=job).count() + total_hired = UserJobApplication.objects.filter(job=job, status='Selected').count() + + response_data = { + "job_id": str(job.id), + "job_title": job.title, + "total_views": total_views, + "total_applications": total_applications, + "total_hired": total_hired, + "conversion_rate_percentage": round((total_applications / total_views) * 100, 2) if total_views > 0 else 0.0 + } + + return CustomResponse( + response=response_data, + general_message="Job analytics fetched successfully" + ).get_success_response() + + except Exception as e: + return CustomResponse( + general_message="Something went wrong", + message={"error_code": "SERVER_ERROR"} + ).get_failure_response(status_code=500) + + diff --git a/api/dashboard/company/mulearner_serializers.py b/api/dashboard/company/mulearner_serializers.py new file mode 100644 index 000000000..cf0ce853b --- /dev/null +++ b/api/dashboard/company/mulearner_serializers.py @@ -0,0 +1,64 @@ +from rest_framework import serializers +from db.user import User + +class MulearnerDirectorySerializer(serializers.ModelSerializer): + karma = serializers.SerializerMethodField() + level = serializers.SerializerMethodField() + college = serializers.SerializerMethodField() + department = serializers.SerializerMethodField() + graduation_year = serializers.SerializerMethodField() + + class Meta: + model = User + fields = [ + 'id', 'full_name', 'muid', 'email', 'karma', 'level', + 'college', 'department', 'graduation_year' + ] + + def get_karma(self, obj): + # Uses annotated_karma from the queryset (Coalesce, no extra DB hit) + annotated = getattr(obj, 'annotated_karma', None) + if annotated is not None: + return annotated + try: + return obj.wallet_user.karma + except Exception: + return 0 + + def get_level(self, obj): + # Uses select_related cache — no extra DB hit + try: + lvl_link = obj.user_lvl_link_user + return lvl_link.level.level_order if lvl_link and lvl_link.level else 0 + except Exception: + return 0 + + def get_college(self, obj): + # Uses prefetch_related cache — no extra DB hit + try: + for org_link in obj.user_organization_link_user.all(): + if org_link.org and org_link.org.org_type == 'College': + return org_link.org.title + return None + except Exception: + return None + + def get_department(self, obj): + # Uses prefetch_related cache — no extra DB hit + try: + for org_link in obj.user_organization_link_user.all(): + if org_link.org and org_link.org.org_type == 'College': + return org_link.department.title if org_link.department else None + return None + except Exception: + return None + + def get_graduation_year(self, obj): + # Uses prefetch_related cache — no extra DB hit + try: + for org_link in obj.user_organization_link_user.all(): + if org_link.org and org_link.org.org_type == 'College': + return org_link.graduation_year + return None + except Exception: + return None diff --git a/api/dashboard/company/mulearner_views.py b/api/dashboard/company/mulearner_views.py new file mode 100644 index 000000000..cef97decd --- /dev/null +++ b/api/dashboard/company/mulearner_views.py @@ -0,0 +1,101 @@ +from rest_framework.views import APIView +from django.db.models import Q, Value, IntegerField +from django.db.models.functions import Coalesce +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.user import User +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from . import mulearner_serializers +from .company_views import _get_company_for_user + +class CompanyMulearnerDirectoryAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Company'], + description="Directory of MuLearners available to companies (creator or company mentor).", + parameters=[ + OpenApiParameter("min_karma", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False), + OpenApiParameter("max_karma", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False), + OpenApiParameter("level", OpenApiTypes.INT, OpenApiParameter.QUERY, required=False), + OpenApiParameter("college", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("department", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("graduation_year", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("ig", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("skill", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("achievement", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("task", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + ], + responses={200: mulearner_serializers.MulearnerDirectorySerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not _get_company_for_user(user_id): + return CustomResponse( + general_message="Access denied. Verified company profile required." + ).get_failure_response(status_code=403) + users = User.objects.filter( + user_settings_user__is_public=True + ).select_related( + "wallet_user", + "user_lvl_link_user__level", + ).prefetch_related( + "user_organization_link_user__org", + "user_organization_link_user__department", + ).annotate( + annotated_karma=Coalesce( + "wallet_user__karma", Value(0), output_field=IntegerField() + ) + ) + min_karma = request.query_params.get('min_karma') + max_karma = request.query_params.get('max_karma') + level = request.query_params.get('level') + college = request.query_params.get('college') + department = request.query_params.get('department') + graduation_year = request.query_params.get('graduation_year') + ig = request.query_params.get('ig') + skill = request.query_params.get('skill') + achievement = request.query_params.get('achievement') + task = request.query_params.get('task') + + if min_karma: + users = users.filter(annotated_karma__gte=int(min_karma)) + if max_karma: + users = users.filter(annotated_karma__lte=int(max_karma)) + if level: + users = users.filter(user_lvl_link_user__level__level_order=level) + if college: + users = users.filter( + user_organization_link_user__org__title__icontains=college, + user_organization_link_user__org__org_type='College' + ) + if department: + users = users.filter(user_organization_link_user__department__title__icontains=department) + if graduation_year: + users = users.filter(user_organization_link_user__graduation_year=graduation_year) + if ig: + users = users.filter(user_ig_link_user__ig__name__icontains=ig) + if skill: + users = users.filter(skill_progress__skill_id=skill) + if achievement: + users = users.filter(achievements__achievement_id=achievement) + if task: + users = users.filter(karma_activity_log_user__task_id=task) + + users = users.distinct() + + paginated_queryset = CommonUtils.get_paginated_queryset( + users, request, + search_fields=["full_name", "muid", "email"], + sort_fields={"full_name": "full_name", "created_at": "created_at", "karma": "wallet_user__karma"} + ) + + serializer = mulearner_serializers.MulearnerDirectorySerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() diff --git a/api/dashboard/company/serializers.py b/api/dashboard/company/serializers.py new file mode 100644 index 000000000..d69a11431 --- /dev/null +++ b/api/dashboard/company/serializers.py @@ -0,0 +1,456 @@ +import uuid +from rest_framework import serializers +from django.utils.text import slugify + +from db.company import Company +from db.organization import Organization, District, State, Country +from db.user import UserRoleLink, Role +from utils.types import RoleType, OrganizationType +from utils.utils import DateTimeUtils + +def generate_unique_code(): + """Generate a 12-char hex code guaranteed to be unique in Organization.code.""" + while True: + code = uuid.uuid4().hex[:12] + if not Organization.objects.filter(code=code).exists(): + return code + +class CompanyRegisterSerializer(serializers.ModelSerializer): + district_id = serializers.PrimaryKeyRelatedField(queryset=District.objects.all(), required=False, allow_null=True, source="district") + state_id = serializers.PrimaryKeyRelatedField(queryset=State.objects.all(), required=False, allow_null=True, source="state") + country_id = serializers.PrimaryKeyRelatedField(queryset=Country.objects.all(), required=False, allow_null=True, source="country") + + class Meta: + model = Company + fields = [ + "name", + "logo", + "description", + "short_pitch", + "industry_sector", + "website_link", + "email", + "location", + "district_id", + "state_id", + "country_id", + "legal_name", + "registration_number", + "tax_id", + "company_size", + "linkedin_url", + "founded_year", + "remote_policy", + "culture_text", + "tech_stack", + "perks", + "testimonials", + "gallery" + ] + + def validate_description(self, value): + if value and len(value) > 5000: + raise serializers.ValidationError("Description must not exceed 5000 characters.") + return value + + def validate_culture_text(self, value): + if value and len(value) > 3000: + raise serializers.ValidationError("Culture text must not exceed 3000 characters.") + return value + + def validate_perks(self, value): + if value and len(value) > 2000: + raise serializers.ValidationError("Perks must not exceed 2000 characters.") + return value + + def validate_testimonials(self, value): + if value and len(value) > 3000: + raise serializers.ValidationError("Testimonials must not exceed 3000 characters.") + return value + + def validate_short_pitch(self, value): + if value: + word_count = len(value.split()) + if word_count > 150: + raise serializers.ValidationError("Short pitch must not exceed 150 words.") + return value + + def validate(self, data): + """Enforce address hierarchy: district must belong to the supplied state/country.""" + district = data.get("district") + state = data.get("state") + country = data.get("country") + + if district and state: + if not hasattr(district, 'zone') or district.zone.state_id != state.id: + raise serializers.ValidationError( + {"district_id": "The selected district does not belong to the selected state."} + ) + if district and country: + if not hasattr(district, 'zone') or district.zone.state.country_id != country.id: + raise serializers.ValidationError( + {"district_id": "The selected district does not belong to the selected country."} + ) + return data + + def create(self, validated_data): + user_id = self.context["user_id"] + + # Auto-generate a slug from the company name + base_slug = slugify(validated_data["name"]) + slug = base_slug + counter = 1 + while Company.objects.filter(slug=slug).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + + company = Company.objects.create( + company_user_id=user_id, + status="pending", + slug=slug, + verification_requested_at=DateTimeUtils.get_current_utc_time(), + created_at=DateTimeUtils.get_current_utc_time(), + updated_at=DateTimeUtils.get_current_utc_time(), + updated_by=user_id, + **validated_data + ) + return company + +class CompanyUpdateSerializer(serializers.ModelSerializer): + district_id = serializers.PrimaryKeyRelatedField(queryset=District.objects.all(), required=False, allow_null=True, source="district") + state_id = serializers.PrimaryKeyRelatedField(queryset=State.objects.all(), required=False, allow_null=True, source="state") + country_id = serializers.PrimaryKeyRelatedField(queryset=Country.objects.all(), required=False, allow_null=True, source="country") + + class Meta: + model = Company + fields = [ + "name", + "logo", + "description", + "short_pitch", + "industry_sector", + "website_link", + "email", + "location", + "district_id", + "state_id", + "country_id", + "legal_name", + "registration_number", + "tax_id", + "company_size", + "linkedin_url", + "founded_year", + "remote_policy", + "culture_text", + "tech_stack", + "perks", + "testimonials", + "gallery" + ] + + def validate_description(self, value): + if value and len(value) > 5000: + raise serializers.ValidationError("Description must not exceed 5000 characters.") + return value + + def validate_culture_text(self, value): + if value and len(value) > 3000: + raise serializers.ValidationError("Culture text must not exceed 3000 characters.") + return value + + def validate_perks(self, value): + if value and len(value) > 2000: + raise serializers.ValidationError("Perks must not exceed 2000 characters.") + return value + + def validate_testimonials(self, value): + if value and len(value) > 3000: + raise serializers.ValidationError("Testimonials must not exceed 3000 characters.") + return value + + def validate_short_pitch(self, value): + if value: + word_count = len(value.split()) + if word_count > 150: + raise serializers.ValidationError("Short pitch must not exceed 150 words.") + return value + + def validate(self, data): + """Enforce address hierarchy: district must belong to the supplied state/country.""" + district = data.get("district") + state = data.get("state") + country = data.get("country") + + if district and state: + if not hasattr(district, 'zone') or district.zone.state_id != state.id: + raise serializers.ValidationError( + {"district_id": "The selected district does not belong to the selected state."} + ) + if district and country: + if not hasattr(district, 'zone') or district.zone.state.country_id != country.id: + raise serializers.ValidationError( + {"district_id": "The selected district does not belong to the selected country."} + ) + return data + + def update(self, instance, validated_data): + validated_data['updated_at'] = DateTimeUtils.get_current_utc_time() + validated_data['updated_by'] = self.context.get("user_id", instance.company_user_id) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + +class CompanyListSerializer(serializers.ModelSerializer): + company_user_name = serializers.CharField(source='company_user.full_name', read_only=True) + district_name = serializers.CharField(source='district.name', read_only=True, default=None) + state_name = serializers.CharField(source='district.zone.state.name', read_only=True, default=None) + country_name = serializers.CharField(source='district.zone.state.country.name', read_only=True, default=None) + + class Meta: + model = Company + fields = [ + "id", + "name", + "slug", + "status", + "email", + "company_user_id", + "company_user_name", + "industry_sector", + "company_size", + "location", + "district_name", + "state_name", + "country_name", + "verification_requested_at", + "verified_at" + ] + +class CompanyDetailSerializer(serializers.ModelSerializer): + company_user_name = serializers.CharField(source='company_user.full_name', read_only=True) + company_user_email = serializers.CharField(source='company_user.email', read_only=True) + district_name = serializers.CharField(source='district.name', read_only=True, default=None) + + class Meta: + model = Company + fields = "__all__" + +class CompanyVerifySerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=["verified", "rejected"]) + rejection_reason = serializers.CharField(required=False, allow_blank=True) + + def validate(self, data): + if data.get("status") == "rejected" and not data.get("rejection_reason"): + raise serializers.ValidationError("Rejection reason is required when rejecting.") + return data + + def update(self, instance, validated_data): + user_id = self.context["user_id"] + status = validated_data.get("status") + + instance.status = status + instance.updated_by = user_id + instance.updated_at = DateTimeUtils.get_current_utc_time() + + if status == "verified": + instance.verified_by = user_id + instance.verified_at = DateTimeUtils.get_current_utc_time() + # Clear any stale rejection data from a previous rejection + instance.rejection_reason = None + + # ── Ensure the company's Organization row exists ───────────────── + org = Organization.objects.filter( + title=instance.name, + org_type=OrganizationType.COMPANY.value, + ).first() + if not org: + org_code = generate_unique_code() + org = Organization.objects.create( + title=instance.name, + code=org_code, + org_type=OrganizationType.COMPANY.value, + district=instance.district, + created_by_id=user_id, + updated_by_id=user_id, + created_at=DateTimeUtils.get_current_utc_time(), + updated_at=DateTimeUtils.get_current_utc_time(), + ) + + # ── Link the company creator to the org ────────────────────────── + from db.organization import UserOrganizationLink + UserOrganizationLink.objects.get_or_create( + user=instance.company_user, + org=org, + defaults={ + "verified": True, + "created_by_id": user_id, + "created_at": DateTimeUtils.get_current_utc_time(), + }, + ) + + # ── Grant the COMPANY role ─────────────────────────────────────── + company_role = Role.objects.filter(title=RoleType.COMPANY.value).first() + if company_role: + UserRoleLink.objects.update_or_create( + user=instance.company_user, + role=company_role, + defaults={ + "verified": True, + "created_by": instance.company_user, + "created_at": DateTimeUtils.get_current_utc_time(), + }, + ) + + elif status == "rejected": + instance.rejection_reason = validated_data.get("rejection_reason") + + instance.save() + return instance + +class PublicCompanyProfileSerializer(serializers.ModelSerializer): + district_name = serializers.CharField(source='district.name', read_only=True, default=None) + state_name = serializers.CharField(source='district.zone.state.name', read_only=True, default=None) + country_name = serializers.CharField(source='district.zone.state.country.name', read_only=True, default=None) + + class Meta: + model = Company + fields = [ + "id", + "name", + "slug", + "logo", + "description", + "short_pitch", + "industry_sector", + "website_link", + "email", + "location", + "district_name", + "state_name", + "country_name", + "company_size", + "linkedin_url", + "founded_year", + "remote_policy", + "culture_text", + "tech_stack", + "perks", + "testimonials", + "gallery" + ] + + +# --------------------------------------------------------------------------- +# Company Mentor serializers +# --------------------------------------------------------------------------- + +from db.user import User as _User, UserMentor + + +class CompanyMentorNominateSerializer(serializers.Serializer): + """Nominate an existing platform user as a Company Mentor for the company. + + The nominated user is identified by their ``muid`` (e.g. john-doe@mulearn) + and must already be linked to the company's Organisation record. + """ + + muid = serializers.CharField( + help_text="MuID of the platform user to nominate (e.g. john-doe@mulearn)." + ) + reason = serializers.CharField( + required=False, allow_blank=True, + help_text="Optional reason / note to pass with the nomination.", + ) + + def validate(self, data): + company = self.context.get("company") + muid = data.get("muid") + + # ── Resolve muid → User ────────────────────────────────────────────── + user = _User.objects.filter(muid=muid).first() + if not user: + raise serializers.ValidationError( + {"muid": f"No platform user found with muid '{muid}'."} + ) + + # ── Resolve company → Organization row ────────────────────────────── + org = Organization.objects.filter( + title=company.name, + org_type=OrganizationType.COMPANY.value, + ).first() + if not org: + raise serializers.ValidationError( + "Company organization record not found. Ensure the company is verified." + ) + + # ── Validate org membership ────────────────────────────────────────── + from db.organization import UserOrganizationLink as _UOL + if not _UOL.objects.filter(user=user, org=org).exists(): + raise serializers.ValidationError( + {"muid": f"User '{muid}' is not a member of this company's organisation."} + ) + + # ── Prevent duplicate active nominations ───────────────────────────── + existing = UserMentor.objects.filter( + user=user, + mentor_tier=UserMentor.MentorTier.COMPANY_MENTOR, + org=org, + ).exclude(status=UserMentor.Status.REJECTED).first() + if existing: + raise serializers.ValidationError( + f"This user already has a {existing.status} Company Mentor nomination for your company." + ) + + data["_user"] = user + data["_org"] = org + return data + + def save(self): + nominator_id = self.context.get("user_id") + user = self.validated_data["_user"] + reason = self.validated_data.get("reason", "") + org = self.validated_data["_org"] + + from utils.utils import DateTimeUtils + current_time = DateTimeUtils.get_current_utc_time() + + mentor = UserMentor.objects.create( + id=str(uuid.uuid4()), + user=user, + mentor_tier=UserMentor.MentorTier.COMPANY_MENTOR, + org=org, + reason=reason, + status=UserMentor.Status.PENDING, + created_by_id=nominator_id, + updated_by_id=nominator_id, + created_at=current_time, + updated_at=current_time, + ) + return mentor + + + +class CompanyMentorListSerializer(serializers.ModelSerializer): + """Serializer for listing Company Mentor nominations.""" + + user_name = serializers.CharField(source="user.full_name", read_only=True) + user_email = serializers.CharField(source="user.email", read_only=True) + org_name = serializers.CharField(source="org.title", read_only=True, default=None) + + class Meta: + model = UserMentor + fields = [ + "id", + "user_id", + "user_name", + "user_email", + "org_name", + "mentor_tier", + "status", + "reason", + "verification_note", + "verified_at", + ] diff --git a/api/dashboard/company/task_serializers.py b/api/dashboard/company/task_serializers.py new file mode 100644 index 000000000..e4b604e42 --- /dev/null +++ b/api/dashboard/company/task_serializers.py @@ -0,0 +1,191 @@ +import uuid + +from rest_framework import serializers + +from db.task import TaskList +from db.skill import TaskSkillLink + + +class CompanyTaskCreateSerializer(serializers.ModelSerializer): + """ + Serializer for company task creation. + Similar to mentor task creation but IG is optional and no IG validation. + Locked fields (active, approval_status, requested_by, requested_at) + are injected by the view via serializer.save(**overrides). + """ + + class Meta: + model = TaskList + fields = ( + "hashtag", + "title", + "karma", + "usage_count", + "description", + "type", + "level", + "created_by", + "updated_by", + ) + extra_kwargs = { + "ig": {"required": False, "allow_null": True}, + } + + def validate_hashtag(self, value): + """Global hashtag uniqueness.""" + qs = TaskList.objects.filter(hashtag=value) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise serializers.ValidationError( + "A task with this hashtag already exists." + ) + return value + + +class CompanyTaskUpdateSerializer(serializers.ModelSerializer): + """ + Partial update serializer — same writable fields as create except created_by. + """ + + class Meta: + model = TaskList + fields = ( + "hashtag", + "title", + "karma", + "usage_count", + "description", + "type", + "level", + "updated_by", + ) + extra_kwargs = { + "ig": {"required": False, "allow_null": True}, + } + + def validate_hashtag(self, value): + """Exclude current instance from uniqueness check on edit.""" + qs = TaskList.objects.filter(hashtag=value) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise serializers.ValidationError( + "A task with this hashtag already exists." + ) + return value + + +class CompanyTaskListSerializer(serializers.ModelSerializer): + """ + Read-only list/detail serializer for company tasks. + """ + channel = serializers.CharField(source="channel.name", required=False, default=None) + type = serializers.CharField(source="type.title") + level = serializers.CharField(source="level.name", required=False, default=None) + ig = serializers.CharField(source="ig.name", required=False, default=None) + org = serializers.CharField(source="org.title", required=False, default=None) + requested_by_name = serializers.CharField( + source="requested_by.full_name", required=False, default=None + ) + skills = serializers.SerializerMethodField() + + class Meta: + model = TaskList + fields = [ + "id", + "hashtag", + "discord_link", + "title", + "description", + "karma", + "channel", + "type", + "active", + "variable_karma", + "usage_count", + "level", + "org", + "ig", + "event", + "bonus_karma", + "bonus_time", + "approval_status", + "rejection_reason", + "reviewed_at", + "requested_by_name", + "requested_at", + "skills", + "created_at", + "updated_at", + ] + + def get_skills(self, obj): + """Return skills linked to this task.""" + skill_links = TaskSkillLink.objects.filter(task_id=obj.id).select_related("skill") + return [ + {"id": link.skill.id, "name": link.skill.name, "code": link.skill.code} + for link in skill_links + ] + + +class CompanyTaskPatchSerializer(serializers.Serializer): + """ + Validates the payload when a company updates an existing task. + All fields are optional to support partial updates. + """ + title = serializers.CharField(max_length=75, required=False) + hashtag = serializers.CharField(max_length=75, required=False) + description = serializers.CharField(required=False, allow_blank=True, allow_null=True) + karma = serializers.IntegerField(min_value=1, required=False) + ig_id = serializers.CharField(max_length=36, required=False) + type_id = serializers.CharField(max_length=36, required=False) + channel_id = serializers.CharField(max_length=36, required=False, allow_null=True) + level_id = serializers.CharField(max_length=36, required=False, allow_null=True) + skill_ids = serializers.ListField( + child=serializers.CharField(max_length=36), + required=False, + allow_empty=True, + help_text="List of active Skill UUIDs to link to this task.", + ) + + def validate_hashtag(self, value): + value = value.strip() + if not value.startswith('#'): + raise serializers.ValidationError("hashtag must start with '#'") + + # Unique validation excluding the current task instance + task_id = self.context.get("task_id") + from db.task import TaskList + qs = TaskList.objects.filter(hashtag__iexact=value) + if task_id: + qs = qs.exclude(id=task_id) + if qs.exists(): + raise serializers.ValidationError(f"A task with hashtag '{value}' already exists.") + return value + + def validate_skill_ids(self, value): + """Validate that all provided skill IDs exist and are active.""" + from db.skill import Skill + invalid_ids = [] + for skill_id in value: + if not Skill.objects.filter(id=skill_id, is_active=True).exists(): + invalid_ids.append(skill_id) + if invalid_ids: + raise serializers.ValidationError( + f"The following skill IDs are invalid or inactive: {', '.join(invalid_ids)}" + ) + return value + + def validate_ig_id(self, value): + from db.task import InterestGroup + if not InterestGroup.objects.filter(id=value).exists(): + raise serializers.ValidationError(f"Interest Group with id '{value}' does not exist.") + return value + + def validate_type_id(self, value): + from db.task import TaskType + if not TaskType.objects.filter(id=value).exists(): + raise serializers.ValidationError(f"TaskType with id '{value}' does not exist.") + return value + diff --git a/api/dashboard/company/task_views.py b/api/dashboard/company/task_views.py new file mode 100644 index 000000000..ca87c283d --- /dev/null +++ b/api/dashboard/company/task_views.py @@ -0,0 +1,378 @@ +import json +import uuid + +from django.db import transaction +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes, OpenApiResponse, inline_serializer +from rest_framework import serializers as s + +from db.task import TaskList +from db.skill import Skill, TaskSkillLink +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils, DateTimeUtils + +from .task_serializers import ( + CompanyTaskCreateSerializer, + CompanyTaskListSerializer, + CompanyTaskUpdateSerializer, + CompanyTaskPatchSerializer, +) + + +def _save_task_skills(task_id, skill_ids, user_id): + """Clear and re-create TaskSkillLink rows.""" + TaskSkillLink.objects.filter(task_id=task_id).delete() + for skill_id in skill_ids: + if Skill.objects.filter(id=skill_id, is_active=True).exists(): + TaskSkillLink.objects.create( + id=str(uuid.uuid4()), + task_id=task_id, + skill_id=skill_id, + created_by_id=user_id, + ) + + +def get_verified_company(user_id): + """ + Return the verified Company for a user if they are: + - the company creator (company_user_id == user_id), OR + - hold an active COMPANY_MENTOR grant for that company. + """ + from api.dashboard.mentor.dash_mentor_helper import get_verified_company_for_mentor + return get_verified_company_for_mentor(user_id) + + +class CompanyTaskListCreateAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Company Task"], + description=( + "List all tasks submitted by the authenticated company user " + "(creator or company mentor). Supports pagination, search, and sort." + ), + parameters=[ + OpenApiParameter( + "approval_status", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + description="Filter by approval_status: pending | approved | rejected", + ), + ], + responses={ + 200: inline_serializer( + "CompanyTaskListResponse", + fields={ + "data": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + }, + ) + }, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + queryset = TaskList.objects.select_related( + "channel", "type", "level", "ig", "org", "requested_by" + ).filter(requested_by_id=user_id) + + approval_status = request.query_params.get("approval_status") + if approval_status: + queryset = queryset.filter(approval_status=approval_status) + else: + # Exclude soft-deleted tasks (active=False after being previously approved) + # Only exclude tasks that are inactive AND already approved/rejected (not pending) + queryset = queryset.exclude(active=False, approval_status="approved") + + paginated = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=[ + "hashtag", + "title", + "description", + "karma", + "ig__name", + "type__title", + "approval_status", + ], + sort_fields={ + "hashtag": "hashtag", + "title": "title", + "karma": "karma", + "ig": "ig__name", + "type": "type__title", + "approval_status": "approval_status", + "created_at": "created_at", + "updated_at": "updated_at", + }, + ) + + serializer = CompanyTaskListSerializer( + paginated.get("queryset"), many=True + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated.get("pagination"), + ) + + @extend_schema( + tags=["Dashboard - Company Task"], + description=( + "Create a new task for the company. " + "The task is saved with approval_status='pending' and active=False " + "until an admin approves it. " + "Accepts an optional skill_ids list." + ), + request=CompanyTaskCreateSerializer, + responses={200: OpenApiResponse(description="Task submitted for approval.")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + + company = get_verified_company(user_id) + if not company: + return CustomResponse( + general_message="You must have a verified company profile to submit tasks." + ).get_failure_response(status_code=403) + + mutable_data = request.data.copy() + mutable_data["created_by"] = user_id + mutable_data["updated_by"] = user_id + + skill_ids = mutable_data.pop("skill_ids", None) + if isinstance(skill_ids, str): + try: + skill_ids = json.loads(skill_ids) + except (json.JSONDecodeError, TypeError): + skill_ids = [] + + serializer = CompanyTaskCreateSerializer( + data=mutable_data, + context={"user_id": user_id}, + ) + if not serializer.is_valid(): + return CustomResponse(message=serializer.errors).get_failure_response() + + with transaction.atomic(): + task = serializer.save( + id=str(uuid.uuid4()), + approval_status="pending", + active=False, + requested_by_id=user_id, + requested_at=DateTimeUtils.get_current_utc_time(), + ) + + if skill_ids: + _save_task_skills(task.id, skill_ids, user_id) + + return CustomResponse( + general_message="Task submitted for approval." + ).get_success_response() + + +class CompanyTaskDetailAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Company Task"], + description="Retrieve the detail of a specific task submitted by the company.", + responses={200: CompanyTaskListSerializer}, + ) + def get(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + company = get_verified_company(user_id) + if not company: + return CustomResponse( + general_message="Access denied. Verified company profile required." + ).get_failure_response(status_code=403) + + task = TaskList.objects.select_related( + "channel", "type", "level", "ig", "org", "requested_by" + ).filter(id=task_id, requested_by_id=user_id).first() + + if not task: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404) + + serializer = CompanyTaskListSerializer(task) + return CustomResponse(response=serializer.data).get_success_response() + + @extend_schema( + tags=["Dashboard - Company Task"], + description=( + "Update a task submitted by the company. " + "Regardless of the current approval_status, the task reverts to " + "'pending' and active=False after editing." + ), + request=CompanyTaskUpdateSerializer, + responses={200: OpenApiResponse(description="Task updated and re-submitted for approval.")}, + ) + def put(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + company = get_verified_company(user_id) + if not company: + return CustomResponse( + general_message="Access denied. Verified company profile required." + ).get_failure_response(status_code=403) + + task = TaskList.objects.filter( + id=task_id, requested_by_id=user_id + ).first() + + if not task: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404) + + mutable_data = request.data.copy() + mutable_data["updated_by"] = user_id + + skill_ids = mutable_data.pop("skill_ids", None) + if isinstance(skill_ids, str): + try: + skill_ids = json.loads(skill_ids) + except (json.JSONDecodeError, TypeError): + skill_ids = None + + serializer = CompanyTaskUpdateSerializer( + task, + data=mutable_data, + partial=True, + context={"user_id": user_id}, + ) + if not serializer.is_valid(): + return CustomResponse(message=serializer.errors).get_failure_response() + + serializer.save( + approval_status="pending", + active=False, + rejection_reason=None, + reviewed_by_admin=None, + reviewed_at=None, + ) + + if skill_ids is not None: + _save_task_skills(task_id, skill_ids, user_id) + + return CustomResponse( + general_message="Task updated and re-submitted for approval." + ).get_success_response() + + @extend_schema( + tags=["Dashboard - Company Task"], + description="Update a task submitted by the company (partial update). Regardless of the current approval_status, the task reverts to pending and active=False.", + request=CompanyTaskPatchSerializer, + responses={200: CompanyTaskListSerializer}, + ) + def patch(self, request, task_id): + from rest_framework import status + user_id = JWTUtils.fetch_user_id(request) + company = get_verified_company(user_id) + if not company: + return CustomResponse( + general_message="Access denied. Verified company profile required." + ).get_failure_response(status_code=403) + + task = TaskList.objects.filter( + id=task_id, requested_by_id=user_id + ).first() + + if not task: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404) + + from db.user import User + user = User.objects.get(id=user_id) + + serializer = CompanyTaskPatchSerializer( + data=request.data, + context={"task_id": task_id} + ) + if not serializer.is_valid(): + return CustomResponse( + general_message="Invalid task submission data.", + message={"error_code": "VALIDATION_ERROR", "errors": serializer.errors}, + ).get_failure_response(status_code=400, http_status_code=status.HTTP_400_BAD_REQUEST) + + data = serializer.validated_data + skill_ids = data.pop("skill_ids", None) + + from db.task import InterestGroup, TaskType, Level + from db.channels import Channel + + # Resolve FKs if provided + if "ig_id" in data: + task.ig = InterestGroup.objects.get(id=data["ig_id"]) + if "type_id" in data: + task.type = TaskType.objects.get(id=data["type_id"]) + if "channel_id" in data: + task.channel = Channel.objects.filter(id=data["channel_id"]).first() if data["channel_id"] else None + if "level_id" in data: + task.level = Level.objects.filter(id=data["level_id"]).first() if data["level_id"] else None + + for field in ["title", "hashtag", "description", "karma"]: + if field in data: + setattr(task, field, data[field]) + + # Enforce reset business rules + task.active = False + task.approval_status = "pending" + task.rejection_reason = None + task.reviewed_by_admin = None + task.reviewed_at = None + task.updated_by = user + + task.save() + + if skill_ids is not None: + _save_task_skills(task.id, skill_ids, user_id) + + return CustomResponse( + general_message="Task updated successfully and submitted for admin review.", + response=CompanyTaskListSerializer(task).data, + ).get_success_response() + + @extend_schema( + tags=["Dashboard - Company Task"], + description="Soft-delete a task submitted by the company by marking it inactive.", + responses={200: OpenApiResponse(description="Task deleted successfully.")}, + ) + def delete(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + company = get_verified_company(user_id) + if not company: + return CustomResponse( + general_message="Access denied. Verified company profile required." + ).get_failure_response(status_code=403) + + task = TaskList.objects.filter( + id=task_id, requested_by_id=user_id + ).first() + + if not task: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404) + + from db.user import User + user = User.objects.get(id=user_id) + + # Soft delete + task.active = False + task.updated_by = user + task.save(update_fields=["active", "updated_by", "updated_at"]) + + return CustomResponse( + general_message="Task deleted successfully.", + response={ + "task_id": str(task.id), + "deleted_at": task.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ') + } + ).get_success_response() + diff --git a/api/dashboard/company/urls.py b/api/dashboard/company/urls.py new file mode 100644 index 000000000..f74e9ef80 --- /dev/null +++ b/api/dashboard/company/urls.py @@ -0,0 +1,38 @@ +from django.urls import path +from . import company_views, job_views, mulearner_views, analytics_views, task_views + +urlpatterns = [ + path("register/", company_views.CompanyRegistrationAPI.as_view()), + path("summary/", company_views.CompanyAdminSummaryAPI.as_view()), + path("home-summary/", analytics_views.CompanyDashboardSummaryAPIView.as_view()), + path("status/", company_views.CompanyStatusAPI.as_view()), + path("profile/", company_views.CompanyProfileAPI.as_view()), + path("profile/public//",company_views.PublicCompanyProfileAPI.as_view()), + path("profile/public//jobs/", job_views.PublicCompanyJobListAPI.as_view()), + path("list/", company_views.CompanyListAPI.as_view()), + path("jobs/", job_views.CompanyJobAPI.as_view()), + path("jobs/all/", job_views.PublicJobAPI.as_view()), + path("jobs//", job_views.CompanyJobDetailAPI.as_view()), + path("jobs//view/", job_views.TrackJobViewAPIView.as_view()), + path("jobs//analytics/", job_views.CompanyJobEngagementAnalyticsAPIView.as_view()), + path("jobs//apply/", job_views.JobApplicationAPI.as_view()), + path("jobs//applications/", job_views.JobApplicationAPI.as_view()), + path("applications/me/", job_views.UserAppliedJobsAPI.as_view()), + path("applications//status/", job_views.ApplicationStatusAPI.as_view()), + path("applications//withdraw/", job_views.UserApplicationWithdrawAPI.as_view()), + path("applications//resubmit/", job_views.UserApplicationResubmitAPI.as_view()), + path("mulearners/", mulearner_views.CompanyMulearnerDirectoryAPI.as_view()), + path("analytics/gigs/", analytics_views.CompanyGigAnalyticsAPI.as_view()), + path("talent-pool/analytics/", analytics_views.CompanyTalentPoolAnalyticsAPIView.as_view()), + + # Company Mentor — Nomination + path("mentor/nominate/", company_views.CompanyMentorNominateAPI.as_view()), + path("mentor/list/", company_views.CompanyMentorListAPI.as_view()), + + # Task Management for Company + path("tasks/", task_views.CompanyTaskListCreateAPI.as_view(), name='company-task-list-create'), + path("tasks//", task_views.CompanyTaskDetailAPI.as_view(), name='company-task-detail'), + + path("/", company_views.CompanyDetailAPI.as_view()), + path("verify//", company_views.CompanyVerifyAPI.as_view()), +] diff --git a/api/dashboard/coupon/coupon_view.py b/api/dashboard/coupon/coupon_view.py index 0a3a1cadc..72272587e 100644 --- a/api/dashboard/coupon/coupon_view.py +++ b/api/dashboard/coupon/coupon_view.py @@ -5,9 +5,21 @@ from db.user import UserCouponLink from utils.response import CustomResponse from utils.types import CouponResponseKey, DiscountTypes +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s class CouponApi(APIView): + @extend_schema(tags=['Dashboard - Coupon'], description="Create Coupon Api.", + responses={200: inline_serializer( + name='CouponApplyResponse', + fields={ + 'discount_type': s.CharField(), + 'discount_value': s.IntegerField(), + 'ticket': s.ListField(child=s.CharField()), + }, + )}, + ) def post(self, request): if not (coupon_code := request.data.get("code")): return CustomResponse(general_message="Coupon code is required").get_failure_response() diff --git a/api/dashboard/discord_moderator/discord_mod_views.py b/api/dashboard/discord_moderator/discord_mod_views.py index 23d7fae46..f7f6f1f1b 100644 --- a/api/dashboard/discord_moderator/discord_mod_views.py +++ b/api/dashboard/discord_moderator/discord_mod_views.py @@ -8,11 +8,18 @@ from utils.permission import CustomizePermission from utils.response import CustomResponse from .serializer import KarmaActivityLogSerializer,LeaderboardSerializer +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s class TaskList(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Discord Moderator'], + description="Retrieve Task List.", + responses={200: KarmaActivityLogSerializer}, + ) def get(self, request): tasks = KarmaActivityLog.objects.all() paginated_queryset = CommonUtils.get_paginated_queryset( @@ -36,6 +43,15 @@ def get(self, request): class PendingTasks(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Discord Moderator'], description="Retrieve Pending Tasks.", + responses={200: inline_serializer( + name='DiscordPendingTasksResponse', + fields={ + 'peer_pending': s.IntegerField(), + 'appraise_pending': s.IntegerField(), + }, + )}, + ) def get(self, request): date = request.query_params.get("date") if date: @@ -58,6 +74,11 @@ def get(self, request): class LeaderBoard(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Discord Moderator'], + description="Retrieve Leader Board.", + responses={200: LeaderboardSerializer}, + ) def get(self, request): choice = request.query_params.get("option", "peer") diff --git a/api/dashboard/district/dash_district_helper.py b/api/dashboard/district/dash_district_helper.py index 2b9ecd37b..fbf4f7d26 100644 --- a/api/dashboard/district/dash_district_helper.py +++ b/api/dashboard/district/dash_district_helper.py @@ -5,4 +5,4 @@ def get_user_college_link(user_id): return UserOrganizationLink.objects.filter( user_id=user_id, org__org_type=OrganizationType.COLLEGE.value - ).first() + ).order_by("-created_at", "-id").first() diff --git a/api/dashboard/district/dash_district_serializer.py b/api/dashboard/district/dash_district_serializer.py index 61bc26ddd..96e66f4d1 100644 --- a/api/dashboard/district/dash_district_serializer.py +++ b/api/dashboard/district/dash_district_serializer.py @@ -137,7 +137,7 @@ class DistrictStudentDetailsSerializer(serializers.Serializer): class Meta: fields = ( - "user_id" + "user_id", "full_name", "karma", "muid", @@ -163,12 +163,12 @@ class DistrictCollegeDetailsSerializer(serializers.Serializer): class Meta: fields = ( - 'id' + 'id', 'title', 'level', 'code', 'lead', - 'lead_number' + 'lead_number', ) def get_lead(self, obj): diff --git a/api/dashboard/district/dash_district_views.py b/api/dashboard/district/dash_district_views.py index 2c7a5e086..3f225a436 100644 --- a/api/dashboard/district/dash_district_views.py +++ b/api/dashboard/district/dash_district_views.py @@ -12,17 +12,28 @@ from utils.utils import CommonUtils from . import dash_district_serializer from .dash_district_helper import get_user_college_link +from drf_spectacular.utils import extend_schema class DistrictDetailAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.DISTRICT_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - District'], + description="Retrieve District Detail.", + responses={200: dash_district_serializer.DistrictDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + serializer = dash_district_serializer.DistrictDetailsSerializer( user_org_link, many=False ) @@ -34,11 +45,21 @@ class DistrictTopThreeCampusAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.DISTRICT_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - District'], + description="Retrieve District Top Three Campus.", + responses={200: dash_district_serializer.DistrictTopThreeCampusSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + org_karma_dict = ( UserOrganizationLink.objects.filter( org__org_type=OrganizationType.COLLEGE.value, @@ -73,11 +94,21 @@ class DistrictStudentLevelStatusAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.DISTRICT_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - District'], + description="Retrieve District Student Level Status.", + responses={200: dash_district_serializer.DistrictStudentLevelStatusSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + district = user_org_link.org.district levels = Level.objects.all() @@ -92,18 +123,28 @@ class DistrictStudentDetailsAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.DISTRICT_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - District'], + description="Retrieve District Student Details.", + responses={200: dash_district_serializer.DistrictStudentDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + rank = ( Wallet.objects.filter( user__user_organization_link_user__org__district=user_org_link.org.district, user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, ) .distinct() - .order_by("-karma", '-update_at', "created_at") + .order_by("-karma", "-updated_at", "created_at") .values( "user_id", "karma", @@ -153,18 +194,28 @@ class DistrictStudentDetailsCSVAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.DISTRICT_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - District'], + description="Retrieve District Student Details C S V.", + responses={200: dash_district_serializer.DistrictStudentDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + rank = ( Wallet.objects.filter( user__user_organization_link_user__org__district=user_org_link.org.district, user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, ) .distinct() - .order_by("-karma", '-updated_at', "created_at") + .order_by("-karma", "-updated_at", "created_at") .values( "user_id", "karma", @@ -196,11 +247,21 @@ class DistrictsCollageDetailsAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.DISTRICT_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - District'], + description="Retrieve Districts Collage Details.", + responses={200: dash_district_serializer.DistrictCollegeDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + organizations = ( Organization.objects.filter( district=user_org_link.org.district, @@ -260,11 +321,21 @@ class DistrictsCollageDetailsCSVAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.DISTRICT_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - District'], + description="Retrieve Districts Collage Details C S V.", + responses={200: dash_district_serializer.DistrictCollegeDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + organizations = ( Organization.objects.filter( district=user_org_link.org.district, diff --git a/api/dashboard/dynamic_management/dynamic_management_view.py b/api/dashboard/dynamic_management/dynamic_management_view.py index 836227f78..5125356f7 100644 --- a/api/dashboard/dynamic_management/dynamic_management_view.py +++ b/api/dashboard/dynamic_management/dynamic_management_view.py @@ -8,12 +8,20 @@ from .dynamic_management_serializer import DynamicRoleCreateSerializer, DynamicRoleListSerializer, \ DynamicRoleUpdateSerializer, RoleDropDownSerializer, DynamicUserCreateSerializer, DynamicUserListSerializer, \ DynamicUserUpdateSerializer +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s class DynamicRoleAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Dynamic Management'], + description="Create Dynamic Role.", + request=DynamicRoleCreateSerializer, + responses={200: DynamicRoleCreateSerializer}, + ) def post(self, request): # create serializer = DynamicRoleCreateSerializer(data=request.data, context={'request': request}) if serializer.is_valid(): @@ -23,6 +31,11 @@ def post(self, request): # create return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Dynamic Management'], + description="Retrieve Dynamic Role.", + responses={200: DynamicRoleListSerializer}, + ) def get(self, request): # list dynamic_roles = DynamicRole.objects.values('type').distinct() data = [{'type': role['type']} for role in dynamic_roles] @@ -38,6 +51,11 @@ def get(self, request): # list pagination=paginated_queryset.get('pagination')) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Dynamic Management'], + description="Delete Dynamic Role.", + responses={200: DynamicRoleUpdateSerializer}, + ) def delete(self, request, type_id): # delete if dynamic_role := DynamicRole.objects.filter(id=type_id).first(): DynamicRoleUpdateSerializer().destroy(dynamic_role) @@ -49,6 +67,9 @@ def delete(self, request, type_id): # delete ).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Dynamic Management'], description="Partially update Dynamic Role.", + responses={200: DynamicRoleCreateSerializer}, + ) def patch(self, request, type_id): user_id = JWTUtils.fetch_user_id(request) context = {'user_id': user_id} @@ -64,6 +85,12 @@ class DynamicUserAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Dynamic Management'], + description="Create Dynamic User.", + request=DynamicUserCreateSerializer, + responses={200: DynamicUserCreateSerializer}, + ) def post(self, request): serializer = DynamicUserCreateSerializer(data=request.data, context={'request': request}) if serializer.is_valid(): @@ -73,6 +100,11 @@ def post(self, request): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Dynamic Management'], + description="Retrieve Dynamic User.", + responses={200: DynamicUserListSerializer}, + ) def get(self, request): dynamic_users = DynamicUser.objects.values('type').distinct() data = [{'type': user['type']} for user in dynamic_users] @@ -88,6 +120,11 @@ def get(self, request): pagination=paginated_queryset.get('pagination')) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Dynamic Management'], + description="Delete Dynamic User.", + responses={200: DynamicUserUpdateSerializer}, + ) def delete(self, request, type_id): if dynamic_user := DynamicUser.objects.filter(id=type_id).first(): DynamicUserUpdateSerializer().destroy(dynamic_user) @@ -99,6 +136,9 @@ def delete(self, request, type_id): ).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Dynamic Management'], description="Partially update Dynamic User.", + responses={200: DynamicUserCreateSerializer}, + ) def patch(self, request, type_id): # update user_id = JWTUtils.fetch_user_id(request) context = {'user_id': user_id} @@ -113,6 +153,12 @@ def patch(self, request, type_id): # update class DynamicTypeDropDownAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Dynamic Management'], description="Retrieve Dynamic Type Drop Down.", + responses={200: inline_serializer( + name='DynamicTypeDropDownResponse', + fields={'types': s.ListField(child=s.CharField())}, + )}, + ) def get(self, request): dynamic_types = ManagementType.get_all_values() return CustomResponse(response=dynamic_types).get_success_response() @@ -121,6 +167,11 @@ def get(self, request): class RoleDropDownAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Dynamic Management'], + description="Retrieve Role Drop Down.", + responses={200: RoleDropDownSerializer}, + ) def get(self, request): roles = Role.objects.all() serializer = RoleDropDownSerializer(roles, many=True) diff --git a/api/dashboard/enabler/__init__.py b/api/dashboard/enabler/__init__.py new file mode 100644 index 000000000..1f79d236a --- /dev/null +++ b/api/dashboard/enabler/__init__.py @@ -0,0 +1 @@ +# enabler app diff --git a/api/dashboard/enabler/enabler_views.py b/api/dashboard/enabler/enabler_views.py new file mode 100644 index 000000000..597ae16fa --- /dev/null +++ b/api/dashboard/enabler/enabler_views.py @@ -0,0 +1,318 @@ +from datetime import timedelta + +from django.db.models import Count, Q, Sum +from django.utils import timezone +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s +from rest_framework.views import APIView + +from db.organization import Organization, UserOrganizationLink, EnablerCampusNote +from db.task import KarmaActivityLog, Wallet +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import OrganizationType, RoleType +from utils.utils import CommonUtils +from . import serializers + + +class EnablerHomeSummaryAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value]) + @extend_schema(tags=['Dashboard - Enabler'], description="Retrieve Enabler Home Summary.", + responses={200: inline_serializer( + name='EnablerHomeSummaryResponse', + fields={ + 'total_campuses': s.IntegerField(), + 'total_students': s.IntegerField(), + 'total_karma': s.IntegerField(), + }, + )}, + ) + def get(self, request): + enabler_id = JWTUtils.fetch_user_id(request) + + # FIX: Add .distinct() to prevent duplicate campus IDs from corrupting aggregations + assigned_campus_ids = UserOrganizationLink.objects.filter( + user_id=enabler_id, + org__org_type=OrganizationType.COLLEGE.value + ).values_list('org_id', flat=True).distinct() + + total_campuses = assigned_campus_ids.count() + + # FIX: Implement real student count — students are users linked to the assigned campuses + total_students = UserOrganizationLink.objects.filter( + org_id__in=assigned_campus_ids, + org__org_type=OrganizationType.COLLEGE.value + ).exclude(user_id=enabler_id).values('user_id').distinct().count() + + # FIX: Implement real total karma — sum karma from Wallet for all students in those campuses + total_karma = Wallet.objects.filter( + user__user_organization_link_user__org_id__in=assigned_campus_ids + ).aggregate(total=Sum('karma'))['total'] or 0 + + return CustomResponse(response={ + "total_campuses": total_campuses, + "total_students": total_students, + "total_karma": total_karma, + }).get_success_response() + + +class EnablerCampusListAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Enabler'], + description="Retrieve Enabler Campus List.", + responses={200: serializers.EnablerCampusListSerializer}, + ) + def get(self, request): + enabler_id = JWTUtils.fetch_user_id(request) + + # FIX: Add .distinct() to avoid showing duplicate campuses if UserOrganizationLink has dups + assigned_campuses_ids = UserOrganizationLink.objects.filter( + user_id=enabler_id, + org__org_type=OrganizationType.COLLEGE.value + ).values_list('org_id', flat=True).distinct() + + campuses = Organization.objects.filter(id__in=assigned_campuses_ids) + paginated_queryset = CommonUtils.get_paginated_queryset(campuses, request, ["title", "code"]) + + serializer = serializers.EnablerCampusListSerializer(paginated_queryset.get('queryset'), many=True) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated_queryset.get('pagination') + ) + + +class EnablerCampusReviewAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Enabler'], + description="Retrieve Enabler Campus Review.", + responses={200: serializers.EnablerCampusListSerializer}, + ) + def get(self, request, campus_id): + enabler_id = JWTUtils.fetch_user_id(request) + + # FIX: Also verify the campus is actually a COLLEGE type org + is_assigned = UserOrganizationLink.objects.filter( + user_id=enabler_id, + org_id=campus_id, + org__org_type=OrganizationType.COLLEGE.value + ).exists() + + if not is_assigned: + return CustomResponse(general_message="Not assigned to this campus").get_failure_response() + + try: + campus = Organization.objects.get(id=campus_id) + serializer = serializers.EnablerCampusListSerializer(campus) + return CustomResponse(response=serializer.data).get_success_response() + except Organization.DoesNotExist: + return CustomResponse(general_message="Campus not found").get_failure_response() + + +class EnablerCampusNoteAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Enabler'], + description="Retrieve Enabler Campus Note.", + responses={200: serializers.EnablerCampusNoteSerializer}, + ) + def get(self, request, campus_id): + enabler_id = JWTUtils.fetch_user_id(request) + + notes = EnablerCampusNote.objects.filter( + campus_id=campus_id, + enabler_id=enabler_id + ).order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset(notes, request, ["note"]) + serializer = serializers.EnablerCampusNoteSerializer(paginated_queryset.get('queryset'), many=True) + + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated_queryset.get('pagination') + ) + + @role_required([RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Enabler'], + description="Create Enabler Campus Note.", + request=serializers.EnablerCampusNoteSerializer, + responses={200: serializers.EnablerCampusNoteSerializer}, + ) + def post(self, request, campus_id): + enabler_id = JWTUtils.fetch_user_id(request) + + # FIX: Also verify the campus is a COLLEGE type org + is_assigned = UserOrganizationLink.objects.filter( + user_id=enabler_id, + org_id=campus_id, + org__org_type=OrganizationType.COLLEGE.value + ).exists() + + if not is_assigned: + return CustomResponse(general_message="Not assigned to this campus").get_failure_response() + + serializer = serializers.EnablerCampusNoteSerializer( + data=request.data, + context={"enabler_id": enabler_id, "campus_id": campus_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse(general_message="Note added successfully").get_success_response() + + return CustomResponse(response=serializer.errors).get_failure_response() + + # FIX: Add missing PATCH endpoint to allow enablers to update/close notes + @role_required([RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Enabler'], + description="Update Enabler Campus Note (e.g., change status from open to closed).", + request=serializers.EnablerCampusNoteUpdateSerializer, + responses={200: serializers.EnablerCampusNoteSerializer}, + ) + def patch(self, request, campus_id): + enabler_id = JWTUtils.fetch_user_id(request) + note_id = request.data.get("note_id") + + if not note_id: + return CustomResponse(general_message="note_id is required").get_failure_response() + + try: + note = EnablerCampusNote.objects.get( + id=note_id, + campus_id=campus_id, + enabler_id=enabler_id # Ensures ownership — an enabler can only edit their own notes + ) + except EnablerCampusNote.DoesNotExist: + return CustomResponse(general_message="Note not found").get_failure_response() + + serializer = serializers.EnablerCampusNoteUpdateSerializer( + note, + data=request.data, + partial=True, + context={"enabler_id": enabler_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse(general_message="Note updated successfully").get_success_response() + + return CustomResponse(response=serializer.errors).get_failure_response() + + # FIX: Add missing DELETE endpoint for note removal + @role_required([RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value]) + @extend_schema( + tags=['Dashboard - Enabler'], + description="Delete an Enabler Campus Note.", + responses={200: inline_serializer(name='DeleteNoteResponse', fields={'message': s.CharField()})}, + ) + def delete(self, request, campus_id): + enabler_id = JWTUtils.fetch_user_id(request) + note_id = request.data.get("note_id") + + if not note_id: + return CustomResponse(general_message="note_id is required").get_failure_response() + + try: + note = EnablerCampusNote.objects.get( + id=note_id, + campus_id=campus_id, + enabler_id=enabler_id # Ensures ownership + ) + except EnablerCampusNote.DoesNotExist: + return CustomResponse(general_message="Note not found").get_failure_response() + + note.delete() + return CustomResponse(general_message="Note deleted successfully").get_success_response() + + +class EnablerReportsAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value]) + @extend_schema(tags=['Dashboard - Enabler'], description="Retrieve Enabler Reports.", + responses={200: inline_serializer( + name='EnablerReportsResponse', + fields={ + 'period': s.CharField(), + 'summary': inline_serializer( + name='EnablerReportsSummary', + fields={ + 'assigned_campuses': s.IntegerField(), + 'active_campuses': s.IntegerField(), + 'at_risk_campuses': s.IntegerField(), + 'followups_closed': s.IntegerField(), + 'followups_pending': s.IntegerField(), + }, + ), + 'campuses': s.ListField(child=s.JSONField()), + }, + )}, + ) + def get(self, request): + enabler_id = JWTUtils.fetch_user_id(request) + thirty_days_ago = timezone.now() - timedelta(days=30) + + # FIX: Add .distinct() to avoid duplicate campus IDs + assigned_campuses_ids = UserOrganizationLink.objects.filter( + user_id=enabler_id, + org__org_type=OrganizationType.COLLEGE.value + ).values_list('org_id', flat=True).distinct() + + assigned_campuses_count = assigned_campuses_ids.count() + + # FIX: Eliminate N+1 — fetch all campus data with annotated note counts in a single query + campuses_qs = Organization.objects.filter( + id__in=assigned_campuses_ids + ).annotate( + open_notes_count=Count( + 'enabler_campus_notes', + filter=Q( + enabler_campus_notes__status='open', + enabler_campus_notes__enabler_id=enabler_id # FIX: Consistent scoping to this enabler + ) + ) + ) + + campuses_data = [] + for campus in campuses_qs: + campuses_data.append({ + "campus_id": campus.id, + "campus_name": campus.title, + "followups_pending": campus.open_notes_count, + }) + + # FIX: Consistent data visibility — scope follow-up counts to this enabler's notes only + followups_closed = EnablerCampusNote.objects.filter( + campus_id__in=assigned_campuses_ids, + enabler_id=enabler_id, + status='closed' + ).count() + + followups_pending = EnablerCampusNote.objects.filter( + campus_id__in=assigned_campuses_ids, + enabler_id=enabler_id, + status='open' + ).count() + + return CustomResponse(response={ + "period": "30d", + "summary": { + "assigned_campuses": assigned_campuses_count, + "active_campuses": assigned_campuses_count, + "at_risk_campuses": 0, + "followups_closed": followups_closed, + "followups_pending": followups_pending, + }, + "campuses": campuses_data, + }).get_success_response() diff --git a/api/dashboard/enabler/serializers.py b/api/dashboard/enabler/serializers.py new file mode 100644 index 000000000..a29bfb659 --- /dev/null +++ b/api/dashboard/enabler/serializers.py @@ -0,0 +1,66 @@ +from django.utils import timezone +from rest_framework import serializers + +from db.organization import Organization, EnablerCampusNote + + +class EnablerCampusListSerializer(serializers.ModelSerializer): + campus_code = serializers.ReadOnlyField(source="code") + college_name = serializers.ReadOnlyField(source="title") + zone = serializers.ReadOnlyField(source="district.zone.name") + + class Meta: + model = Organization + fields = ["id", "college_name", "campus_code", "zone", "org_type"] + + +class EnablerCampusNoteSerializer(serializers.ModelSerializer): + enabler_name = serializers.CharField(source="enabler.full_name", read_only=True) + + class Meta: + model = EnablerCampusNote + fields = ["id", "enabler_name", "note", "status", "priority", "follow_up_date", "created_at", "updated_at"] + read_only_fields = ["id", "enabler_name", "created_at", "updated_at"] + + # FIX: Validate that follow_up_date is not in the past + def validate_follow_up_date(self, value): + if value and value < timezone.now().date(): + raise serializers.ValidationError("Follow-up date cannot be in the past.") + return value + + def create(self, validated_data): + enabler_id = self.context.get("enabler_id") + campus_id = self.context.get("campus_id") + + note = EnablerCampusNote.objects.create( + enabler_id=enabler_id, + campus_id=campus_id, + created_by_id=enabler_id, + updated_by_id=enabler_id, + **validated_data + ) + return note + + def update(self, instance, validated_data): + enabler_id = self.context.get("enabler_id") + instance.updated_by_id = enabler_id + return super().update(instance, validated_data) + + +class EnablerCampusNoteUpdateSerializer(serializers.ModelSerializer): + """Serializer specifically for partial updates to a note (e.g., change status, priority, note text).""" + + class Meta: + model = EnablerCampusNote + fields = ["note", "status", "priority", "follow_up_date"] + + # FIX: Validate that follow_up_date is not in the past on updates too + def validate_follow_up_date(self, value): + if value and value < timezone.now().date(): + raise serializers.ValidationError("Follow-up date cannot be in the past.") + return value + + def update(self, instance, validated_data): + enabler_id = self.context.get("enabler_id") + instance.updated_by_id = enabler_id + return super().update(instance, validated_data) diff --git a/api/dashboard/enabler/urls.py b/api/dashboard/enabler/urls.py new file mode 100644 index 000000000..db6f7ec5c --- /dev/null +++ b/api/dashboard/enabler/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import enabler_views + +urlpatterns = [ + path("home-summary/", enabler_views.EnablerHomeSummaryAPI.as_view(), name="enabler-home-summary"), + path("campuses/", enabler_views.EnablerCampusListAPI.as_view(), name="enabler-campuses"), + path("campuses//review/", enabler_views.EnablerCampusReviewAPI.as_view(), name="enabler-campus-review"), + path("campuses//notes/", enabler_views.EnablerCampusNoteAPI.as_view(), name="enabler-campus-notes"), + path("reports/", enabler_views.EnablerReportsAPI.as_view(), name="enabler-reports"), +] diff --git a/api/dashboard/error_log/error_view.py b/api/dashboard/error_log/error_view.py index 2809dee20..eadb7fe4c 100644 --- a/api/dashboard/error_log/error_view.py +++ b/api/dashboard/error_log/error_view.py @@ -10,6 +10,8 @@ from utils.types import RoleType from .log_helper import ManageURLPatterns, logHandler +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s class DownloadErrorLogAPI(APIView): @@ -18,6 +20,9 @@ class DownloadErrorLogAPI(APIView): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.TECH_TEAM.value] ) + @extend_schema(tags=['Dashboard - Error Log'], description="Retrieve Download Error Log.", + responses={200: OpenApiResponse(description="Binary log file download (application/octet-stream)")}, + ) def get(self, request, log_name): error_log = f"{settings.LOG_PATH}/{log_name}.log" if os.path.exists(error_log): @@ -37,6 +42,14 @@ class ViewErrorLogAPI(APIView): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.TECH_TEAM.value] ) + @extend_schema(tags=['Dashboard - Error Log'], description="Retrieve View Error Log.", + responses={200: inline_serializer("ErrorLogViewResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.CharField(help_text="Raw log file contents as a string"), + })}, + ) def get(self, request, log_name): error_log = f"{settings.LOG_PATH}/{log_name}.log" if os.path.exists(error_log): @@ -60,6 +73,9 @@ class ClearErrorLogAPI(APIView): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.TECH_TEAM.value] ) + @extend_schema(tags=['Dashboard - Error Log'], description="Create Clear Error Log.", + responses={200: OpenApiResponse(description="Log file cleared successfully")}, + ) def post(self, request, log_name): error_log = f"{settings.LOG_PATH}/{log_name}.log" if os.path.exists(error_log): @@ -103,6 +119,17 @@ class LoggerAPI(APIView): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.TECH_TEAM.value] ) + @extend_schema(tags=['Dashboard - Error Log'], description="Retrieve Logger.", + responses={200: inline_serializer("ErrorLogLoggerResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.ListField( + child=s.DictField(), + help_text="Parsed list of structured error log entries", + ), + })}, + ) def get(self, request): """ Get the error logs. @@ -134,6 +161,17 @@ def get(self, request): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.TECH_TEAM.value] ) + @extend_schema(tags=['Dashboard - Error Log'], description="Partially update Logger.", + responses={200: inline_serializer("ErrorLogPatchResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.CharField( + default="Updated patch list", + help_text="Confirmation string indicating the patch list was updated", + ), + })}, + ) def patch(self, request, error_id): """ Patch the error log. @@ -170,6 +208,21 @@ class ErrorGraphAPI(APIView): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.TECH_TEAM.value] ) + @extend_schema(tags=['Dashboard - Error Log'], description="Retrieve Error Graph.", + responses={200: inline_serializer("ErrorLogGraphResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": inline_serializer("ErrorLogGraphData", fields={ + "heatmap": s.DictField(help_text="URL hit frequency map keyed by endpoint"), + "incident_info": s.DictField(help_text="Summary counts of incidents"), + "affected_users": s.ListField( + child=s.CharField(), + help_text="List of user identifiers affected by logged errors", + ), + }), + })}, + ) def get(self, request): """ Handle the GET request to retrieve formatted error data. @@ -217,6 +270,17 @@ class ErrorTabAPI(APIView): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.TECH_TEAM.value] ) + @extend_schema(tags=['Dashboard - Error Log'], description="Retrieve Error Tab.", + responses={200: inline_serializer("ErrorLogTabResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(default={}), + "response": s.ListField( + child=s.DictField(), + help_text="Parsed list of structured error log entries", + ), + })}, + ) def get(self, request): """ Handle the GET request to retrieve grouped URL patterns. diff --git a/api/dashboard/error_log/log_helper.py b/api/dashboard/error_log/log_helper.py index 93a18a161..89fe37b8b 100644 --- a/api/dashboard/error_log/log_helper.py +++ b/api/dashboard/error_log/log_helper.py @@ -113,7 +113,7 @@ def __init__(self, log_data) -> None: # Log entries their types and how to find them self.log_entries = { "id": {"regex": r"ID: (.+?)\n(?=TYPE:)", "type": str}, - "timestamp": {"regex": r"\n(.+?) ERROR.*", "type": datetime}, + "timestamp": {"regex": r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[,\.]\d{3}) ERROR", "type": datetime}, "type": {"regex": r"TYPE: (.+?)\n(?=MESSAGE:)", "type": str}, "message": {"regex": r"MESSAGE: (.+?)\n(?=METHOD:)", "type": str}, "method": {"regex": r"METHOD: (.+?)\n(?=PATH:)", "type": str}, diff --git a/api/dashboard/events/admin_views.py b/api/dashboard/events/admin_views.py new file mode 100644 index 000000000..1e3474038 --- /dev/null +++ b/api/dashboard/events/admin_views.py @@ -0,0 +1,330 @@ +""" +Admin Events API views. +All endpoints require the 'Admins' role. +""" +from rest_framework.views import APIView + +from db.events import Event, EventLog +from db.user import User +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.utils import CommonUtils +from utils.types import RoleType +from api.notification.notifications_utils import NotificationUtils +from api.notification.broadcast_utils import BroadcastUtils + +from .serializers import EventListItemSerializer, EventDetailSerializer, get_live_events +from .event_logger import log_event_action +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s + + +PENDING_STATUSES = [ + Event.Status.PENDING_CAMPUS_APPROVAL, + Event.Status.PENDING_APPROVAL, + Event.Status.PENDING_MENTOR_APPROVAL, +] + +# Maps current status → approved status +APPROVAL_TRANSITIONS = { + Event.Status.PENDING_CAMPUS_APPROVAL: Event.Status.PENDING_APPROVAL, + Event.Status.PENDING_APPROVAL: Event.Status.PUBLISHED, + Event.Status.PENDING_MENTOR_APPROVAL: Event.Status.PUBLISHED, +} + + +class AdminEventListAPI(APIView): + """ + GET /events/admin/ + Returns ALL events on the platform (all statuses, including cancelled). + Supports additional admin filters: organiser_type, created_by. + """ + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Admin Event List.", + responses={200: EventListItemSerializer}, + ) + def get(self, request): + events = Event.objects.all().select_related('category', 'organiser_ig', 'organiser_org') + + params = request.query_params + if status := params.get('status'): + events = events.filter(status=status) + if organiser_type := params.get('organiser_type'): + events = events.filter(organiser_type=organiser_type) + if created_by := params.get('created_by'): + events = events.filter(created_by_id=created_by) + if scope := params.get('scope'): + events = events.filter(scope=scope) + if is_featured := params.get('is_featured'): + events = events.filter(is_featured=is_featured.lower() == 'true') + + paginated = CommonUtils.get_paginated_queryset( + events, request, + search_fields=['title', 'description', 'venue_city'], + sort_fields={ + 'created_at': 'created_at', + 'start_datetime': 'start_datetime', + 'interest_count': '-interest_count', + }, + ) + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={ + 'user_id': JWTUtils.fetch_user_id(request), + 'request': request, + }, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class AdminEventApproveAPI(APIView): + """ + POST /events/admin//approve/ + Advances a pending event through the approval pipeline. + + Transitions: + pending_campus_approval → pending_approval + pending_approval → published + pending_mentor_approval → published + """ + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Events'], description="Create Admin Event Approve.", + responses={200: inline_serializer( + name='EventApproveResponse', + fields={ + 'id': s.CharField(), + 'status': s.CharField(), + }, + )}, + ) + def post(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if event.status not in APPROVAL_TRANSITIONS: + return CustomResponse( + general_message=f'Event is not in a pending state (current: {event.status}).' + ).get_failure_response() + + old_status = event.status + new_status = APPROVAL_TRANSITIONS[event.status] + # Campus events scoped to their own campus have no admin stage — + # campus-level approval publishes them directly. + if ( + old_status == Event.Status.PENDING_CAMPUS_APPROVAL + and event.organiser_type == Event.OrganiserType.CAMPUS + and event.scope == Event.Scope.CAMPUS + ): + new_status = Event.Status.PUBLISHED + event.status = new_status + event.updated_by_id = user_id + event.save() + + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.APPROVED, + changes={'Status': {'from': old_status, 'to': new_status}}, + ) + + # Notify the event creator + actor = User.objects.filter(id=user_id).first() + creator = event.created_by + if creator and actor: + if new_status == Event.Status.PUBLISHED: + NotificationUtils.insert_notification( + user=creator, + title='Event Published', + description=f'Your event "{event.title}" is now live!', + button='View', + url=f'/events/{event.id}/', + created_by=actor, + ) + else: + NotificationUtils.insert_notification( + user=creator, + title='Event Approved', + description=f'Your event "{event.title}" has been approved and is progressing through review.', + button=None, + url=None, + created_by=actor, + ) + + # Fire audience broadcast when the event reaches PUBLISHED + if new_status == Event.Status.PUBLISHED and actor: + if event.organiser_type == Event.OrganiserType.CAMPUS_IG: + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new Campus IG event "{event.title}" is now live!', + target_type='campus_ig', + target_id=event.organiser_ci_id or event.scope_ci_id, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + elif event.organiser_type == Event.OrganiserType.GLOBAL_IG: + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new Interest Group event "{event.title}" is now live!', + target_type='interest_group', + target_id=event.organiser_ig_id, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + elif event.organiser_type == Event.OrganiserType.CAMPUS: + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new Campus event "{event.title}" is now live!', + target_type='campus', + target_id=event.scope_org_id, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + else: + # ADMIN / COMPANY → global broadcast + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new event "{event.title}" is now available!', + target_type='global', + target_id=None, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + + return CustomResponse( + general_message=f'Event approved: {old_status} → {new_status}.', + response={'id': event.id, 'status': new_status}, + ).get_success_response() + + +class AdminEventRejectAPI(APIView): + """ + POST /events/admin//reject/ + Rejects a pending event, changing its status to 'rejected'. + Body: { "reason": "..." } + """ + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Events'], description="Create Admin Event Reject.", + responses={200: inline_serializer( + name='EventRejectResponse', + fields={ + 'id': s.CharField(), + 'status': s.CharField(), + 'reason': s.CharField(), + }, + )}, + ) + def post(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if event.status not in PENDING_STATUSES: + return CustomResponse( + general_message=f'Event is not in a pending state (current: {event.status}).' + ).get_failure_response() + + reason = request.data.get('reason', '').strip() + if not reason: + return CustomResponse( + general_message='A rejection reason is required.' + ).get_failure_response() + + old_status = event.status + event.status = Event.Status.REJECTED + event.updated_by_id = user_id + event.save() + + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.REJECTED, + changes={'Status': {'from': old_status, 'to': Event.Status.REJECTED}}, + details={'reason': reason}, + ) + + # Notify the event creator + actor = User.objects.filter(id=user_id).first() + creator = event.created_by + if creator and actor: + NotificationUtils.insert_notification( + user=creator, + title='Event Rejected', + description=f'Your event "{event.title}" was rejected. Reason: {reason}', + button=None, + url=None, + created_by=actor, + ) + + return CustomResponse( + general_message=f'Event rejected (was: {old_status}).', + response={'id': event.id, 'status': Event.Status.REJECTED, 'reason': reason}, + ).get_success_response() + + +class AdminEventFeatureAPI(APIView): + """ + PATCH /events/admin//feature/ + Toggles is_featured on/off. + Optionally accepts body: { "is_featured": true/false } + If not provided, current value is toggled. + """ + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Events'], description="Partially update Admin Event Feature.", + responses={200: inline_serializer( + name='EventFeatureResponse', + fields={ + 'id': s.CharField(), + 'is_featured': s.BooleanField(), + }, + )}, + ) + def patch(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if 'is_featured' in request.data: + new_value = bool(request.data['is_featured']) + else: + new_value = not event.is_featured # toggle + + event.is_featured = new_value + event.updated_by_id = user_id + event.save() + + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.FEATURED if new_value else EventLog.Action.UNFEATURED, + changes={'Featured': {'from': not new_value, 'to': new_value}}, + ) + + action = 'featured' if new_value else 'unfeatured' + return CustomResponse( + general_message=f'Event has been {action}.', + response={'id': event.id, 'is_featured': new_value}, + ).get_success_response() diff --git a/api/dashboard/events/analytics_views.py b/api/dashboard/events/analytics_views.py new file mode 100644 index 000000000..0e9272fd5 --- /dev/null +++ b/api/dashboard/events/analytics_views.py @@ -0,0 +1,141 @@ +""" +Event Analytics API views. +Provides performance stats for event organisers. +""" +from rest_framework.views import APIView + +from django.db.models import Sum, Count +from django.db.models.functions import TruncDate + +from db.events import EventInterest +from db.task import TaskList, KarmaActivityLog +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.types import RoleType + +from .serializers import can_manage_event, get_live_events +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s + + +class EventAnalyticsAPI(APIView): + """ + GET /events/manage//analytics/ + Returns performance stats for the event: + - summary (interests, tasks, completions, karma) + - interest_trend (day-by-day) + - task_breakdown (per-task completions + karma) + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve analytics and performance stats for an event.", + responses={200: inline_serializer( + name='EventAnalyticsResponse', + fields={ + 'summary': s.DictField(), + 'interest_trend': s.ListField(child=s.DictField()), + 'task_breakdown': s.ListField(child=s.DictField()), + }, + )}, + ) + def get(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse( + general_message='Event not found.' + ).get_failure_response() + + is_admin = RoleType.ADMIN.value in roles + if not is_admin and not can_manage_event(user_id, event): + return CustomResponse( + general_message='You do not have permission to view analytics for this event.' + ).get_failure_response() + + # ── Linked tasks ───────────────────────────────────────── + linked_tasks = TaskList.objects.filter(event_fk=event) + total_tasks = linked_tasks.count() + approved_tasks = linked_tasks.filter(approval_status='approved').count() + pending_tasks = linked_tasks.filter(approval_status='pending').count() + + # ── Interest stats ─────────────────────────────────────── + interests = EventInterest.objects.filter(event=event) + total_interests = interests.count() + + # Interest trend (day-by-day) + interest_trend = list( + interests.annotate( + date=TruncDate('expressed_at') + ).values('date').annotate( + count=Count('id') + ).order_by('date') + ) + # Format dates to ISO strings + interest_trend = [ + { + 'date': item['date'].isoformat() if item['date'] else None, + 'count': item['count'], + } + for item in interest_trend + ] + + # ── Task completion stats (from KarmaActivityLog) ──────── + task_ids = list(linked_tasks.values_list('id', flat=True)) + + completion_logs = KarmaActivityLog.objects.filter( + task_id__in=task_ids, + appraiser_approved=True, + ) + + total_completions = completion_logs.count() + total_karma_awarded = completion_logs.aggregate( + total=Sum('karma') + )['total'] or 0 + + # Group and aggregate completions and karma per task + completions_aggregate = completion_logs.values('task_id').annotate( + completions_count=Count('id'), + total_karma=Sum('karma') + ) + # Build a map of task_id -> {completions_count, total_karma} for O(1) lookups + task_stats = { + str(item['task_id']): { + 'completions_count': item['completions_count'], + 'total_karma': item['total_karma'] or 0 + } + for item in completions_aggregate + } + + # Per-task breakdown + task_breakdown = [] + for task in linked_tasks.select_related('type'): + stats = task_stats.get(str(task.id), {'completions_count': 0, 'total_karma': 0}) + task_breakdown.append({ + 'task_id': str(task.id), + 'title': task.title, + 'hashtag': task.hashtag, + 'karma': task.karma, + 'approval_status': task.approval_status, + 'completions': stats['completions_count'], + 'total_karma_awarded': stats['total_karma'], + }) + + return CustomResponse( + general_message='Event analytics retrieved.', + response={ + 'summary': { + 'total_interests': total_interests, + 'total_tasks': total_tasks, + 'approved_tasks': approved_tasks, + 'pending_tasks': pending_tasks, + 'total_task_completions': total_completions, + 'total_karma_awarded': total_karma_awarded, + }, + 'interest_trend': interest_trend, + 'task_breakdown': task_breakdown, + }, + ).get_success_response() diff --git a/api/dashboard/events/event_image_utils.py b/api/dashboard/events/event_image_utils.py new file mode 100644 index 000000000..a2bccb6ea --- /dev/null +++ b/api/dashboard/events/event_image_utils.py @@ -0,0 +1,359 @@ +""" +Event cover/banner images: validate uploads, fetch remote URLs safely, resolve public URLs. +""" +from __future__ import annotations + +import ipaddress +import json +import os +import socket +import uuid +from io import BytesIO +from urllib.parse import urljoin, urlparse + +import requests +from decouple import config +from django.conf import settings +from PIL import Image + +MAX_BYTES = 5 * 1024 * 1024 +ALLOWED_EXT = frozenset({'png', 'jpg', 'jpeg', 'gif', 'webp'}) +FETCH_TIMEOUT = 15 +MAX_REDIRECTS = 5 + +# PIL format -> file extension (stored in DB path) +_PIL_FORMAT_EXT = { + 'PNG': 'png', + 'JPEG': 'jpg', + 'GIF': 'gif', + 'WEBP': 'webp', +} + + +def _extension_from_filename(name: str) -> str: + if not name or '.' not in name: + return '' + return name.rsplit('.', 1)[-1].lower() + + +def _validate_extension(ext: str) -> bool: + return ext in ALLOWED_EXT + + +def _ensure_dir(subdir: str) -> str: + upload_dir = os.path.join(settings.MEDIA_ROOT, 'events', subdir) + os.makedirs(upload_dir, exist_ok=True) + return upload_dir + + +def save_uploaded_event_image(upload, subdir: str) -> tuple[str | None, str | None]: + """ + Save an uploaded file under MEDIA_ROOT/events//. + Returns (relative_path, error_message). + """ + ext = _extension_from_filename(getattr(upload, 'name', '') or '') + if not _validate_extension(ext): + return None, ( + f'Invalid image type. Allowed: {", ".join(sorted(ALLOWED_EXT))}' + ) + if upload.size > MAX_BYTES: + return None, 'File size exceeds 5MB limit' + + upload.seek(0) + raw = upload.read(MAX_BYTES + 1) + if len(raw) > MAX_BYTES: + return None, 'File size exceeds 5MB limit' + + ext2, err = _validate_image_bytes(raw, ext) + if err: + return None, err + final_ext = ext2 or ext + + unique = f'{uuid.uuid4()}.{final_ext}' + upload_dir = _ensure_dir(subdir) + rel = f'events/{subdir}/{unique}' + abs_path = os.path.join(upload_dir, unique) + with open(abs_path, 'wb+') as dest: + dest.write(raw) + return rel, None + + +def _validate_image_bytes(data: bytes, filename_ext: str) -> tuple[str | None, str | None]: + """Returns (canonical_ext or None, error).""" + try: + img = Image.open(BytesIO(data)) + img.verify() + except Exception: + return None, 'Invalid or corrupted image file' + + try: + img = Image.open(BytesIO(data)) + fmt = (img.format or '').upper() + pil_ext = _PIL_FORMAT_EXT.get(fmt) + if not pil_ext: + return None, 'Unsupported image format' + if not _validate_extension(pil_ext): + return None, 'Unsupported image format' + # Filename should match actual content when possible + if filename_ext and filename_ext != pil_ext and not ( + filename_ext == 'jpeg' and pil_ext == 'jpg' + ): + return pil_ext, None + return pil_ext, None + except Exception: + return None, 'Invalid or corrupted image file' + + +def _hostname_is_blocked(hostname: str) -> bool: + if not hostname: + return True + try: + infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except OSError: + return True + for info in infos: + ip_str = info[4][0] + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + continue + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + ): + return True + if ip.version == 6 and ip in ipaddress.ip_network('fc00::/7'): + return True + return False + + +def _url_is_safe_for_fetch(url: str) -> bool: + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https'): + return False + if not parsed.hostname: + return False + if '@' in parsed.netloc: + return False + return not _hostname_is_blocked(parsed.hostname) + + +def try_normalize_media_url_to_relative(url: str) -> str | None: + """If url points at this deployment's MEDIA_URL, return stored relative path.""" + u = (url or '').strip() + if not u.startswith(('http://', 'https://')): + return None + base = config('BE_DOMAIN_NAME', default='').rstrip('/') + if not base: + return None + prefix = f'{base}{settings.MEDIA_URL}'.rstrip('/') + '/' + if u.startswith(prefix): + return u[len(prefix) :].lstrip('/') + return None + + +def fetch_event_image_from_url(url: str, subdir: str) -> tuple[str | None, str | None]: + """ + Download an image from a remote URL with SSRF checks and size limits. + Returns (relative_path, error_message). + """ + u = (url or '').strip() + if not u: + return None, 'Empty image URL' + + normalized = try_normalize_media_url_to_relative(u) + if normalized: + if normalized.startswith(f'events/{subdir}/'): + return normalized, None + return normalized, None + + if not u.startswith(('http://', 'https://')): + # Relative path or opaque string — store as-is (legacy) + return u, None + + current = u + session = requests.Session() + for _ in range(MAX_REDIRECTS + 1): + if not _url_is_safe_for_fetch(current): + return None, 'URL is not allowed' + try: + resp = session.get( + current, + timeout=FETCH_TIMEOUT, + stream=True, + allow_redirects=False, + headers={'User-Agent': 'mulearnbackend-event-image/1.0'}, + ) + except requests.RequestException as exc: + return None, f'Failed to download image: {exc}' + + if resp.status_code in (301, 302, 303, 307, 308): + loc = resp.headers.get('Location') + if not loc: + return None, 'Redirect without Location header' + current = urljoin(current, loc) + continue + + if resp.status_code != 200: + return None, f'Failed to download image (HTTP {resp.status_code})' + + chunks: list[bytes] = [] + total = 0 + for chunk in resp.iter_content(chunk_size=65536): + if not chunk: + continue + total += len(chunk) + if total > MAX_BYTES: + return None, 'Downloaded file exceeds 5MB limit' + chunks.append(chunk) + data = b''.join(chunks) + break + else: + return None, 'Too many redirects' + + ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip().lower() + if ctype and not ctype.startswith('image/'): + return None, 'URL did not return an image' + + ext_guess = 'jpg' + if 'png' in ctype: + ext_guess = 'png' + elif 'jpeg' in ctype or 'jpg' in ctype: + ext_guess = 'jpg' + elif 'gif' in ctype: + ext_guess = 'gif' + elif 'webp' in ctype: + ext_guess = 'webp' + + ext2, err = _validate_image_bytes(data, ext_guess) + if err: + return None, err + final_ext = ext2 or ext_guess + + unique = f'{uuid.uuid4()}.{final_ext}' + upload_dir = _ensure_dir(subdir) + rel = f'events/{subdir}/{unique}' + abs_path = os.path.join(upload_dir, unique) + with open(abs_path, 'wb+') as dest: + dest.write(data) + return rel, None + + +def delete_stale_event_media(old_path: str | None, new_path: str | None) -> None: + """Remove a previously stored file under events/ when replaced.""" + if not old_path or old_path == new_path: + return + if not old_path.startswith('events/'): + return + full = os.path.join(settings.MEDIA_ROOT, old_path.replace('/', os.sep)) + if os.path.isfile(full): + try: + os.remove(full) + except OSError: + pass + + +def resolve_event_image_url(value: str | None, request=None) -> str | None: + """ + Build an absolute URL for API responses. + Relative paths under media get BE_DOMAIN_NAME + MEDIA_URL; existing http(s) left as-is. + """ + if not value: + return None + v = value.strip() + if not v: + return None + if v.startswith('http://') or v.startswith('https://'): + return v + base = config('BE_DOMAIN_NAME', default='').rstrip('/') + path = v.lstrip('/') + rel_url = f'{settings.MEDIA_URL.rstrip("/")}/{path}' + if base: + return f'{base}{rel_url}' + if request: + return request.build_absolute_uri(rel_url) + return rel_url + + +def _querydict_to_plain_dict(data) -> dict: + """Single-value snapshot of QueryDict / dict for serializer input.""" + out = {} + try: + keys = getattr(data, 'keys', lambda: [])() + for k in keys: + out[k] = data.get(k) + except Exception: + out = dict(data) if hasattr(data, 'items') else {} + return out + + +def merge_event_write_payload(request, *, partial: bool, event=None) -> tuple[dict | None, str | None]: + """ + Merge multipart/JSON body with resolved cover_image / banner_image paths. + Returns (payload_dict, error_message). + """ + payload = _querydict_to_plain_dict(request.data) + + if 'tags' in payload and isinstance(payload['tags'], str): + raw_tags = payload['tags'].strip() + if not raw_tags: + payload['tags'] = None + else: + try: + payload['tags'] = json.loads(raw_tags) + except json.JSONDecodeError: + return None, 'Invalid JSON for tags' + + for field, subdir in ( + ('cover_image', 'covers'), + ('banner_image', 'banners'), + ): + upload = request.FILES.get(field) + if upload: + rel, err = save_uploaded_event_image(upload, subdir) + if err: + return None, err + payload[field] = rel + continue + + if partial: + has_key = field in request.data + if not has_key: + payload.pop(field, None) + continue + else: + has_key = field in request.data + if not has_key: + continue + + raw = request.data.get(field) + if raw is None: + payload[field] = None + continue + if isinstance(raw, str): + raw = raw.strip() + if raw == '' or raw is None: + payload[field] = None + continue + + if not isinstance(raw, str): + payload[field] = raw + continue + + normalized = try_normalize_media_url_to_relative(raw) + if normalized: + payload[field] = normalized + continue + + if raw.lower().startswith(('http://', 'https://')): + rel, err = fetch_event_image_from_url(raw, subdir) + if err: + return None, err + payload[field] = rel + else: + payload[field] = raw + + return payload, None diff --git a/api/dashboard/events/event_logger.py b/api/dashboard/events/event_logger.py new file mode 100644 index 000000000..ceac8008a --- /dev/null +++ b/api/dashboard/events/event_logger.py @@ -0,0 +1,143 @@ +""" +event_logger.py — Centralized audit-log helper for Events. + +All views should call `log_event_action()` instead of creating EventLog +rows directly. This keeps the log format consistent and in one place. + +Stored structure inside EventLog.changed_fields (a JSONField): +{ + "action": "", + "changes": { # only for field-level edits + "Human Field Label": {"from": ..., "to": ...}, + ... + }, + "details": { # for connection events & misc + "": , + ... + } +} +""" + +import uuid + +from db.events import EventLog + + +# ───────────────────────────────────────────────────────────────────────────── +# Human-readable labels for raw Event field names +# ───────────────────────────────────────────────────────────────────────────── + +FIELD_LABELS = { + 'title': 'Title', + 'description': 'Description', + 'cover_image': 'Cover Image', + 'banner_image': 'Banner Image', + 'category': 'Category', + 'category_id': 'Category', + 'start_datetime': 'Start Date & Time', + 'end_datetime': 'End Date & Time', + 'registration_url': 'Registration URL', + 'registration_deadline':'Registration Deadline', + 'min_karma': 'Minimum Karma', + 'venue_type': 'Venue Type', + 'venue_address': 'Venue Address', + 'venue_city': 'Venue City', + 'venue_maps_url': 'Venue Maps URL', + 'venue_online_link': 'Online Link', + 'venue_platform': 'Online Platform', + 'scope': 'Event Scope', + 'scope_org': 'Scope Campus / Company', + 'scope_org_id': 'Scope Campus / Company', + 'scope_ig': 'Scope Interest Group', + 'scope_ig_id': 'Scope Interest Group', + 'scope_ci_id': 'Scope Campus-IG', + 'organiser_type': 'Organiser Type', + 'organiser_ig': 'Organiser IG', + 'organiser_ig_id': 'Organiser IG', + 'organiser_org': 'Organiser Campus / Company', + 'organiser_org_id': 'Organiser Campus / Company', + 'organiser_ci_id': 'Organiser Campus-IG', + 'is_collaboration': 'Collaboration Event', + 'is_featured': 'Featured', + 'tags': 'Tags', + 'user_limit': 'User Limit', + 'status': 'Status', + 'deleted_at': 'Cancelled At', +} + + +def _serialize_value(value): + """Convert a value to something JSON-safe and human-readable.""" + if value is None: + return None + # Django model instance — extract PK + str representation + if hasattr(value, 'pk') and hasattr(value, '__str__'): + return {'id': str(value.pk), 'name': str(value)} + # Datetime + if hasattr(value, 'isoformat'): + return value.isoformat() + return value + + +# ───────────────────────────────────────────────────────────────────────────── +# Public helpers +# ───────────────────────────────────────────────────────────────────────────── + +def build_diff(instance, validated_data): + """ + Compare `validated_data` (incoming) against the current `instance` values + and return a dict of *only the fields that actually changed*, using + human-readable labels. + + Call this BEFORE saving the instance. + + Returns: + { + "Title": {"from": "Old Title", "to": "New Title"}, + "Venue City": {"from": "Kochi", "to": "Thrissur"}, + ... + } + """ + changes = {} + for field, new_value in validated_data.items(): + label = FIELD_LABELS.get(field, field.replace('_', ' ').title()) + + # Resolve the current value on the instance + try: + old_value = getattr(instance, field) + except AttributeError: + old_value = None + + serialized_old = _serialize_value(old_value) + serialized_new = _serialize_value(new_value) + + # Only record genuinely changed fields + if serialized_old != serialized_new: + changes[label] = {'from': serialized_old, 'to': serialized_new} + + return changes + + +def log_event_action(event, user_id, action, changes=None, details=None): + """ + Create an EventLog row with a structured `changed_fields` payload. + + Args: + event — Event instance + user_id — str UUID of the actor + action — one of EventLog.Action constants + changes — dict from build_diff() or a manual before/after dict + details — dict of extra context (e.g. co-owner name, rejection reason) + """ + payload = {'action': action} + if changes: + payload['changes'] = changes + if details: + payload['details'] = details + + EventLog.objects.create( + id=str(uuid.uuid4()), + event=event, + edited_by_id=user_id, + changed_fields=payload, + ) diff --git a/api/dashboard/events/events_serializer.py b/api/dashboard/events/events_serializer.py deleted file mode 100644 index 75624e84e..000000000 --- a/api/dashboard/events/events_serializer.py +++ /dev/null @@ -1,39 +0,0 @@ -import uuid -from rest_framework import serializers -from db.task import Events -from utils.utils import DateTimeUtils - - -class EventsListSerializer(serializers.ModelSerializer): - created_by = serializers.CharField(source="created_by.full_name") - updated_by = serializers.CharField(source="updated_by.full_name") - - class Meta: - model = Events - fields = "__all__" - - -class EventsCUDSerializer(serializers.ModelSerializer): - class Meta: - model = Events - fields = ['name', 'description'] - - def create(self, validated_data): - user_id = self.context.get("user_id") - - validated_data["created_by_id"] = user_id - validated_data["updated_by_id"] = user_id - validated_data["id"] = uuid.uuid4() - return Events.objects.create(**validated_data) - - def update(self, instance, validated_data): - user_id = self.context.get("user_id") - - instance.name = validated_data.get("name", instance.name) - instance.description = validated_data.get( - "description", instance.description) - instance.updated_by_id = user_id - instance.updated_at = DateTimeUtils.get_current_utc_time() - instance.save() - - return instance diff --git a/api/dashboard/events/events_views.py b/api/dashboard/events/events_views.py deleted file mode 100644 index 483acfb34..000000000 --- a/api/dashboard/events/events_views.py +++ /dev/null @@ -1,95 +0,0 @@ -from rest_framework.views import APIView - -from db.task import Events -from utils.permission import CustomizePermission, JWTUtils -from utils.response import CustomResponse -from utils.utils import CommonUtils -from .events_serializer import EventsCUDSerializer, EventsListSerializer - - -class EventAPI(APIView): - authentication_classes = [CustomizePermission] - - def get(self, request): - events = Events.objects.all() - paginated_queryset = CommonUtils.get_paginated_queryset( - events, - request, - ['id', 'name'] - ) - - serializer = EventsListSerializer( - paginated_queryset.get("queryset"), - many=True - ) - - return CustomResponse().paginated_response( - data=serializer.data, - pagination=paginated_queryset.get( - "pagination" - ) - ) - - def post(self, request): - user_id = JWTUtils.fetch_user_id(request) - - serializer = EventsCUDSerializer( - data=request.data, - context={ - "user_id": user_id, - } - ) - - if serializer.is_valid(): - serializer.save() - - return CustomResponse( - general_message=f"{request.data.get('name')} Event created successfully", - response=serializer.data - ).get_success_response() - - return CustomResponse( - general_message=serializer.errors, - ).get_failure_response() - - def put(self, request, event_id): - user_id = JWTUtils.fetch_user_id(request) - - events = Events.objects.filter(id=event_id).first() - - if events is None: - return CustomResponse( - general_message="Invalid Event id" - ).get_failure_response() - - serializer = EventsCUDSerializer( - events, - data=request.data, - context={"user_id": user_id} - ) - - if serializer.is_valid(): - serializer.save() - - return CustomResponse( - general_message=f"{events.name} Edited Successfully" - ).get_success_response() - - return CustomResponse( - message=serializer.errors - ).get_failure_response() - - def delete(self, request, event_id): - - events = Events.objects.filter(id=event_id).first() - - if events is None: - return CustomResponse( - general_message="Invalid event id" - ).get_failure_response() - - events.delete() - - return CustomResponse( - general_message=f"{events.name} Deleted Successfully" - ).get_success_response() diff --git a/api/dashboard/events/manage_views.py b/api/dashboard/events/manage_views.py new file mode 100644 index 000000000..be2358e40 --- /dev/null +++ b/api/dashboard/events/manage_views.py @@ -0,0 +1,1691 @@ +""" +Manage Events API views. +Organiser / co-owner access required for all endpoints. +""" +import uuid +from django.utils import timezone +from django.db import transaction +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.views import APIView + +from db.events import Event, EventConnection, EventLog +from db.user import User +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.utils import CommonUtils +from utils.types import RoleType +from api.notification.notifications_utils import NotificationUtils +from api.notification.broadcast_utils import BroadcastUtils + +from .serializers import ( + EventListItemSerializer, + EventDetailSerializer, + EventCoOwnerSerializer, + EventCollaboratorSerializer, + MyEventInviteSerializer, + EventLogSerializer, + EventWriteSerializer, + can_manage_event, + get_live_events, +) +from .event_logger import log_event_action +from .event_image_utils import delete_stale_event_media, merge_event_write_payload +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s + + +MANAGEABLE_ROLES = { + RoleType.ADMIN.value, + RoleType.CAMPUS_LEAD.value, + RoleType.IG_LEAD.value, + RoleType.ZONAL_CAMPUS_LEAD.value, + RoleType.DISTRICT_CAMPUS_LEAD.value, + RoleType.COMPANY.value, + RoleType.ENABLER.value, + RoleType.LEAD_ENABLER.value, + RoleType.MENTOR.value, +} + + +def _can_create_event(roles): + """True if user holds at least one event-creation role.""" + if MANAGEABLE_ROLES.intersection(set(roles)): + return True + # Dynamic IG/campus roles: e.g. "WEBDEV IGLead", "WEBDEV CampusLead" + return any(r.endswith(' IGLead') or r.endswith(' CampusLead') for r in roles) + + +def _get_manageable_events(): + """Base queryset for manage views: includes cancelled events but not hard-deleted rows. + Organisers need to see their own cancelled events (soft-delete sets deleted_at).""" + return Event.objects.all() # No deleted_at filter — managers see everything they own + + +def _get_user_company_org_ids(user_id, roles): + """Returns a list of Organization IDs for companies where the user is a creator or mentor.""" + company_org_ids = set() + + if RoleType.COMPANY.value in roles: + from db.company import Company + from db.organization import Organization + from utils.types import OrganizationType + company = Company.objects.filter(company_user_id=user_id, status="verified").first() + if company: + org = Organization.objects.filter(title=company.name, org_type=OrganizationType.COMPANY.value).first() + if org: + company_org_ids.add(org.id) + + if RoleType.MENTOR.value in roles: + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import get_scope_ids + company_org_ids |= get_scope_ids(user_id, MentorScopeGrant.ScopeType.COMPANY_MENTOR) + + return list(company_org_ids) + + +def _validate_campus_event_ownership(user_id, roles, organiser_type, organiser_org_id): + """Tenancy guard for Campus (campus-wide) events. + + Returns an error message if the caller is not a member of the campus the + event targets, else None. Admins bypass. Prevents a lead of one campus from + creating/publishing campus-wide events scoped to a different campus. + + Note: Campus IG events are intentionally not covered here. The current + event schema stores only the IG (scope_ci_id / organiser_ci_id) for + campus_ig events and does not reliably record the owning campus + (scope_org is null from the create wizard), so campus-level ownership + cannot be enforced for them yet. + """ + if RoleType.ADMIN.value in roles: + return None + + if organiser_type != Event.OrganiserType.CAMPUS.value: + return None + + if not organiser_org_id: + return 'A target campus is required for campus events.' + + from db.organization import UserOrganizationLink + is_member = UserOrganizationLink.objects.filter( + user_id=user_id, org_id=organiser_org_id + ).exists() + if not is_member: + return 'You are not authorized to create events for this campus.' + return None + + +def _resolve_creator_campus_id(user_id): + """Return the College org id (campus) the given user belongs to, or None. + + Prefers an approved Campus Mentor's scoped org, otherwise falls back to the + user's College organisation link. Used to stamp the owning campus + (scope_org) onto Campus IG events, which the create wizard does not send. + """ + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import get_scope_ids + campus_org_ids = get_scope_ids(user_id, MentorScopeGrant.ScopeType.CAMPUS_MENTOR) + if campus_org_ids: + return next(iter(campus_org_ids)) + + from db.organization import UserOrganizationLink + from utils.types import OrganizationType + link = UserOrganizationLink.objects.filter( + user_id=user_id, + org__org_type=OrganizationType.COLLEGE.value, + ).first() + return link.org_id if link else None + + +# ───────────────────────────────────────────────────────────────────────────── +# MANAGE EVENT LIST + CREATE +# ───────────────────────────────────────────────────────────────────────────── + +class ManageEventListCreateAPI(APIView): + """ + GET /events/manage/ → events the caller can manage + POST /events/manage/ → create a new event + """ + authentication_classes = [CustomizePermission] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Manage Event List Create.", + responses={200: EventListItemSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + is_admin = RoleType.ADMIN.value in roles + + if is_admin: + # Admins can view/edit all events (excluding drafts) from the manage list. + events = _get_manageable_events().exclude(status=Event.Status.DRAFT) + else: + # Events created by the user + events where user is co_owner + co_owned_event_ids = list( + EventConnection.objects.filter( + entity_type=EventConnection.EntityType.CO_OWNER, + entity_id=user_id, + ).values_list('event_id', flat=True) + ) + + from django.db.models import Q + q_filter = Q(created_by_id=user_id) | Q(id__in=co_owned_event_ids) + + # Allow Company and Company Mentors to see all events for their company + if RoleType.COMPANY.value in roles or RoleType.MENTOR.value in roles: + company_org_ids = _get_user_company_org_ids(user_id, roles) + if company_org_ids: + q_filter |= Q( + organiser_type=Event.OrganiserType.COMPANY.value, + organiser_org_id__in=company_org_ids + ) + + # Use _get_manageable_events() so cancelled events remain visible to their owner + events = _get_manageable_events().filter(q_filter) + + # Optional status filter & Queue Scoping + if status := request.query_params.get('status'): + if not is_admin: + if status == Event.Status.PENDING_MENTOR_APPROVAL and RoleType.MENTOR.value in roles: + # A mentor can hold multiple tiers simultaneously (grants + # are additive) — union the pending-approval queues for + # every tier they actually hold, instead of picking one + # arbitrary tier via .first(). + from django.db.models import Q as _Q + from db.user import MentorScopeGrant + from db.task import UserIgLink + from api.dashboard.mentor.dash_mentor_helper import get_scope_ids + + campus_org_ids = get_scope_ids(user_id, MentorScopeGrant.ScopeType.CAMPUS_MENTOR) + user_ig_ids = list( + UserIgLink.objects.filter( + user_id=user_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True + ).values_list('ig_id', flat=True) + ) + + scope_filter = _Q() + has_any_scope = False + if campus_org_ids: + scope_filter |= _Q( + organiser_type=Event.OrganiserType.CAMPUS_IG, + scope_org_id__in=campus_org_ids, + ) + has_any_scope = True + if user_ig_ids: + scope_filter |= _Q( + organiser_type=Event.OrganiserType.GLOBAL_IG, + organiser_ig_id__in=user_ig_ids, + ) + has_any_scope = True + + if has_any_scope: + events = _get_manageable_events().filter(status=status).filter(scope_filter) + else: + events = _get_manageable_events().none() + elif status == Event.Status.PENDING_CAMPUS_APPROVAL and bool(set(roles) & {RoleType.CAMPUS_LEAD.value, RoleType.ZONAL_CAMPUS_LEAD.value, RoleType.DISTRICT_CAMPUS_LEAD.value}): + from db.organization import UserOrganizationLink + user_org_ids = UserOrganizationLink.objects.filter(user_id=user_id).values_list('org_id', flat=True) + events = _get_manageable_events().filter( + status=status, + scope_org_id__in=user_org_ids + ) + else: + events = events.filter(status=status) + else: + events = events.filter(status=status) + + paginated = CommonUtils.get_paginated_queryset( + events.select_related('category', 'organiser_ig', 'organiser_org'), request, + search_fields=['title', 'venue_city'], + sort_fields={'created_at': '-created_at', 'start_datetime': 'start_datetime'}, + ) + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': user_id, 'request': request}, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + @extend_schema( + tags=['Dashboard - Events'], + description="Create Manage Event List Create.", + request=EventWriteSerializer, + responses={200: EventDetailSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + if not _can_create_event(roles): + return CustomResponse( + general_message='You do not have permission to create events.' + ).get_failure_response() + + # Enforce Mentor Creation Scopes + if RoleType.MENTOR.value in roles and not (set(roles) & MANAGEABLE_ROLES - {RoleType.MENTOR.value}): + # User is ONLY a mentor. A mentor can hold multiple tiers + # simultaneously (grants are additive) — check the requested + # organiser_type against whichever scopes they actually hold, + # instead of pinning them to one arbitrary tier via .first(). + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import get_mentor_scopes + scopes = get_mentor_scopes(user_id) + if not scopes: + return CustomResponse(general_message='Active mentor profile not found.').get_failure_response() + + has_campus = any(st == MentorScopeGrant.ScopeType.CAMPUS_MENTOR for st, _ in scopes) + has_company = any(st == MentorScopeGrant.ScopeType.COMPANY_MENTOR for st, _ in scopes) + has_ig = any(st == MentorScopeGrant.ScopeType.IG_MENTOR for st, _ in scopes) + + payload_organiser_type = request.data.get('organiser_type') + + if payload_organiser_type == Event.OrganiserType.CAMPUS_IG.value: + if not has_campus: + return CustomResponse(general_message='Campus Mentors can only create Campus IG events.').get_failure_response() + elif payload_organiser_type == Event.OrganiserType.COMPANY.value: + if not has_company: + return CustomResponse(general_message='Company Mentors can only create Company events.').get_failure_response() + elif payload_organiser_type == Event.OrganiserType.GLOBAL_IG.value: + if not has_ig: + return CustomResponse(general_message='IG Mentors can only create Global IG events.').get_failure_response() + # Verify that the IG being requested is one they mentor + payload_organiser_ig = request.data.get('organiser_ig') + if not payload_organiser_ig: + return CustomResponse(general_message='organiser_ig is required.').get_failure_response() + from db.task import UserIgLink + is_assigned = UserIgLink.objects.filter( + user_id=user_id, + ig_id=payload_organiser_ig, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True + ).exists() + if not is_assigned: + return CustomResponse(general_message='You are not authorized to create events for this Interest Group.').get_failure_response() + else: + return CustomResponse(general_message='This mentor tier is not authorized to create events yet.').get_failure_response() + + payload, merge_error = merge_event_write_payload( + request, partial=False, event=None, + ) + if merge_error: + return CustomResponse(general_message=merge_error).get_failure_response() + + # Enforce organiser_org for Company events + if payload.get('organiser_type') == Event.OrganiserType.COMPANY.value: + payload_organiser_org = payload.get('organiser_org') + if not payload_organiser_org: + return CustomResponse(general_message='organiser_org is required for Company events.').get_failure_response() + + if RoleType.ADMIN.value not in roles: + valid_org_ids = set(_get_user_company_org_ids(user_id, roles)) + if str(payload_organiser_org) not in [str(o) for o in valid_org_ids]: + return CustomResponse(general_message='You are not authorized to create events for this company.').get_failure_response() + + # Enforce campus tenancy for Campus events + ownership_error = _validate_campus_event_ownership( + user_id, roles, + payload.get('organiser_type'), + payload.get('organiser_org'), + ) + if ownership_error: + return CustomResponse(general_message=ownership_error).get_failure_response() + + # Campus IG events: stamp the owning campus (scope_org) from the creator. + # The wizard only sends the IG (scope_ci_id); the campus is implicit and + # required downstream for mentor/campus approval routing and scoping. + # Non-admins are pinned to their own campus (blocks cross-campus + # targeting); admins may target a campus explicitly via scope_org. + if payload.get('organiser_type') == Event.OrganiserType.CAMPUS_IG.value: + if RoleType.ADMIN.value in roles: + if not payload.get('scope_org'): + payload['scope_org'] = _resolve_creator_campus_id(user_id) + else: + campus_id = _resolve_creator_campus_id(user_id) + if not campus_id: + return CustomResponse( + general_message='You are not associated with a campus.' + ).get_failure_response() + payload['scope_org'] = campus_id + + serializer = EventWriteSerializer( + data=payload, + context={'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors, + ).get_failure_response() + + event = serializer.save() + + return CustomResponse( + general_message='Event created successfully.', + response=EventDetailSerializer( + event, context={'user_id': user_id, 'request': request}, + ).data, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# MANAGE EVENT DETAIL (GET / PUT / PATCH / DELETE) +# ───────────────────────────────────────────────────────────────────────────── + +class ManageEventDetailAPI(APIView): + """ + GET /events/manage// → full detail + edit history + PUT /events/manage// → full update + PATCH /events/manage// → partial update + DELETE /events/manage// → soft cancel + """ + authentication_classes = [CustomizePermission] + parser_classes = [MultiPartParser, FormParser, JSONParser] + + def _get_managed_event(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + # Use _get_manageable_events() so managers can see/edit their cancelled events + event = _get_manageable_events().filter(id=event_id).first() + if not event: + return None, None, None, 'Event not found.' + is_admin = RoleType.ADMIN.value in roles + if not is_admin and not can_manage_event(user_id, event): + return None, None, None, 'You do not have permission to manage this event.' + return event, user_id, roles, None + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Manage Event Detail.", + responses={200: EventDetailSerializer}, + ) + def get(self, request, event_id): + event, user_id, _, error = self._get_managed_event(request, event_id) + if error: + return CustomResponse(general_message=error).get_failure_response() + + logs = EventLog.objects.filter(event=event).order_by('-edited_at') + event_data = EventDetailSerializer( + event, + context={ + 'user_id': user_id, 'is_manage_view': True, 'request': request, + }, + ).data + event_data['edit_history'] = EventLogSerializer(logs, many=True).data + + return CustomResponse( + general_message='Event detail retrieved.', + response=event_data, + ).get_success_response() + + @extend_schema(tags=['Dashboard - Events'], description="Update Manage Event Detail.", + responses={200: EventDetailSerializer}, + ) + def put(self, request, event_id): + return self._update(request, event_id, partial=False) + + @extend_schema(tags=['Dashboard - Events'], description="Partially update Manage Event Detail.", + responses={200: EventDetailSerializer}, + ) + def patch(self, request, event_id): + return self._update(request, event_id, partial=True) + + def _update(self, request, event_id, partial): + event, user_id, _, error = self._get_managed_event(request, event_id) + if error: + return CustomResponse(general_message=error).get_failure_response() + + if event.status in (Event.Status.CANCELLED, Event.Status.COMPLETED): + return CustomResponse( + general_message=f'Cannot edit a {event.status} event.' + ).get_failure_response() + + old_cover = event.cover_image + old_banner = event.banner_image + + payload, merge_error = merge_event_write_payload( + request, partial=partial, event=event, + ) + if merge_error: + return CustomResponse(general_message=merge_error).get_failure_response() + + # Enforce Mentor Update Scopes + roles = JWTUtils.fetch_role(request) + if RoleType.MENTOR.value in roles and not (set(roles) & MANAGEABLE_ROLES - {RoleType.MENTOR.value}): + # User is ONLY a mentor. Check the requested organiser_type + # against whichever scopes they actually hold (multi-tier + # mentors can manage events for any tier they hold), instead of + # pinning them to one arbitrary tier via .first(). + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import get_mentor_scopes + scopes = get_mentor_scopes(user_id) + if not scopes: + return CustomResponse(general_message='Active mentor profile not found.').get_failure_response() + + has_campus = any(st == MentorScopeGrant.ScopeType.CAMPUS_MENTOR for st, _ in scopes) + has_company = any(st == MentorScopeGrant.ScopeType.COMPANY_MENTOR for st, _ in scopes) + has_ig = any(st == MentorScopeGrant.ScopeType.IG_MENTOR for st, _ in scopes) + + payload_organiser_type = payload.get('organiser_type', event.organiser_type) + + if payload_organiser_type == Event.OrganiserType.CAMPUS_IG.value: + if not has_campus: + return CustomResponse(general_message='Campus Mentors can only manage Campus IG events.').get_failure_response() + elif payload_organiser_type == Event.OrganiserType.COMPANY.value: + if not has_company: + return CustomResponse(general_message='Company Mentors can only manage Company events.').get_failure_response() + elif payload_organiser_type == Event.OrganiserType.GLOBAL_IG.value: + if not has_ig: + return CustomResponse(general_message='IG Mentors can only manage Global IG events.').get_failure_response() + # Verify that the IG being updated/requested is one they mentor + payload_organiser_ig = payload.get('organiser_ig', event.organiser_ig_id) + if not payload_organiser_ig: + return CustomResponse(general_message='organiser_ig is required.').get_failure_response() + from db.task import UserIgLink + is_assigned = UserIgLink.objects.filter( + user_id=user_id, + ig_id=payload_organiser_ig, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True + ).exists() + if not is_assigned: + return CustomResponse(general_message='You are not authorized to manage events for this Interest Group.').get_failure_response() + else: + return CustomResponse(general_message='This mentor tier is not authorized to manage events yet.').get_failure_response() + + # Enforce organiser_org for Company events + if payload.get('organiser_type', event.organiser_type) == Event.OrganiserType.COMPANY.value: + payload_organiser_org = payload.get('organiser_org', event.organiser_org_id) + if not payload_organiser_org: + return CustomResponse(general_message='organiser_org is required for Company events.').get_failure_response() + + if RoleType.ADMIN.value not in roles: + valid_org_ids = set(_get_user_company_org_ids(user_id, roles)) + if str(payload_organiser_org) not in [str(o) for o in valid_org_ids]: + return CustomResponse(general_message='You are not authorized to manage events for this company.').get_failure_response() + + # Enforce campus tenancy for Campus events + ownership_error = _validate_campus_event_ownership( + user_id, roles, + payload.get('organiser_type', event.organiser_type), + payload.get('organiser_org', event.organiser_org_id), + ) + if ownership_error: + return CustomResponse(general_message=ownership_error).get_failure_response() + + # Campus IG events: keep scope_org pinned to the owning campus (derived + # from the original creator). Prevents tampering and backfills legacy + # events that were created without a campus. + effective_organiser_type = payload.get('organiser_type', event.organiser_type) + if effective_organiser_type == Event.OrganiserType.CAMPUS_IG.value: + campus_id = _resolve_creator_campus_id(event.created_by_id) + if campus_id: + payload['scope_org'] = campus_id + + serializer = EventWriteSerializer( + event, data=payload, + partial=partial, + context={'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse(general_message=serializer.errors).get_failure_response() + + serializer.save() + delete_stale_event_media(old_cover, event.cover_image) + delete_stale_event_media(old_banner, event.banner_image) + + return CustomResponse( + general_message='Event updated successfully.', + response=EventDetailSerializer( + event, context={'user_id': user_id, 'request': request}, + ).data, + ).get_success_response() + + @extend_schema(tags=['Dashboard - Events'], description="Delete Manage Event Detail.", + responses={200: EventDetailSerializer}, + ) + def delete(self, request, event_id): + event, user_id, _, error = self._get_managed_event(request, event_id) + if error: + return CustomResponse(general_message=error).get_failure_response() + + if event.status == Event.Status.CANCELLED: + return CustomResponse( + general_message='Event is already cancelled.' + ).get_failure_response() + + old_status = event.status + event.status = Event.Status.CANCELLED + event.deleted_at = timezone.now() + event.updated_by_id = user_id + event.save() + + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.CANCELLED, + changes={'Status': {'from': old_status, 'to': Event.Status.CANCELLED}}, + ) + + # Broadcast cancellation to all users who expressed interest. + # url is intentionally None — the event page is no longer valid. + actor = User.objects.filter(id=user_id).first() + if actor: + BroadcastUtils.create_broadcast( + title='Event Cancelled', + description=f'The event "{event.title}" has been cancelled.', + target_type='event_interest', + target_id=event.id, + created_by=actor, + expiry_key='event_cancelled', + url=None, + ) + + # Nullify the deep-link URL on all existing broadcast and direct + # notifications that pointed to this event's page. The page is no + # longer valid after cancellation. + event_url = f'/events/{event.id}/' + from db.notification import Notification as DirectNotification, BroadcastNotification + BroadcastNotification.objects.filter(url=event_url).update(url=None) + DirectNotification.objects.filter(url=event_url).update(url=None) + + return CustomResponse( + general_message='Event has been cancelled.', + response={'id': event.id, 'status': Event.Status.CANCELLED}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# PUBLISH +# ───────────────────────────────────────────────────────────────────────────── + +class ManageEventPublishAPI(APIView): + """ + POST /events/manage//publish/ + Transitions a draft event into the approval pipeline. + """ + authentication_classes = [CustomizePermission] + + REQUIRED_FIELDS = [ + 'title', 'description', 'start_datetime', 'end_datetime', + 'venue_type', 'organiser_type', + ] + + @extend_schema(tags=['Dashboard - Events'], description="Create Manage Event Publish.", + responses={200: inline_serializer( + name='EventPublishResponse', + fields={ + 'id': s.CharField(), + 'status': s.CharField(), + }, + )}, + ) + def post(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if not (RoleType.ADMIN.value in roles or can_manage_event(user_id, event)): + return CustomResponse( + general_message='You do not have permission to manage this event.' + ).get_failure_response() + + if event.status not in (Event.Status.DRAFT, Event.Status.REJECTED): + return CustomResponse( + general_message=f'Only draft or rejected events can be published (current: {event.status}).' + ).get_failure_response() + + # Validate required fields are filled + missing = [f for f in self.REQUIRED_FIELDS if not getattr(event, f, None)] + if missing: + return CustomResponse( + general_message=f'Cannot publish: missing fields: {", ".join(missing)}.' + ).get_failure_response() + + # Must be in the future + if event.start_datetime and event.start_datetime <= timezone.now(): + return CustomResponse( + general_message='Cannot publish: start_datetime must be in the future.' + ).get_failure_response() + + # Determine next status based on organiser type + if RoleType.ADMIN.value in roles: + new_status = Event.Status.PUBLISHED + elif event.organiser_type == Event.OrganiserType.CAMPUS_IG: + # Check if creator holds an active CAMPUS_MENTOR grant for this campus + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import has_scope + is_campus_mentor = has_scope(user_id, MentorScopeGrant.ScopeType.CAMPUS_MENTOR, event.scope_org_id) + if is_campus_mentor: + new_status = Event.Status.PENDING_CAMPUS_APPROVAL + else: + new_status = Event.Status.PENDING_MENTOR_APPROVAL + elif event.organiser_type == Event.OrganiserType.GLOBAL_IG: + # Check if creator holds an active IG_MENTOR grant (any IG) and + # is specifically assigned (via UserIgLink) to this one. + from db.user import MentorScopeGrant + from db.task import UserIgLink + from api.dashboard.mentor.dash_mentor_helper import get_scope_ids + is_mentor = bool(get_scope_ids(user_id, MentorScopeGrant.ScopeType.IG_MENTOR)) + is_assigned = UserIgLink.objects.filter( + user_id=user_id, + ig_id=event.organiser_ig_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True + ).exists() + if is_mentor and is_assigned: + new_status = Event.Status.PENDING_APPROVAL + else: + new_status = Event.Status.PENDING_MENTOR_APPROVAL + elif event.organiser_type == Event.OrganiserType.CAMPUS: + # Campus events scoped to the organiser's own campus never require + # admin approval: the campus lead is the approving authority, so + # their own events publish directly. Any wider scope still goes + # through admin approval. + campus_authority = { + RoleType.CAMPUS_LEAD.value, + RoleType.ZONAL_CAMPUS_LEAD.value, + RoleType.DISTRICT_CAMPUS_LEAD.value, + } + if event.scope != Event.Scope.CAMPUS: + new_status = Event.Status.PENDING_APPROVAL + elif set(roles) & campus_authority: + new_status = Event.Status.PUBLISHED + else: + new_status = Event.Status.PENDING_CAMPUS_APPROVAL + else: + new_status = Event.Status.PENDING_APPROVAL + + old_status = event.status + event.status = new_status + event.updated_by_id = user_id + event.save() + + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.PUBLISHED, + changes={'Status': {'from': old_status, 'to': new_status}}, + details={'new_status': new_status}, + ) + + # Fire a broadcast when the event is directly published (admin fast-path) + if new_status == Event.Status.PUBLISHED: + actor = User.objects.filter(id=user_id).first() + if actor: + if event.organiser_type == Event.OrganiserType.CAMPUS_IG: + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new Campus IG event "{event.title}" is now live!', + target_type='campus_ig', + target_id=event.organiser_ci_id or event.scope_ci_id, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + elif event.organiser_type == Event.OrganiserType.GLOBAL_IG: + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new Interest Group event "{event.title}" is now live!', + target_type='interest_group', + target_id=event.organiser_ig_id, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + elif event.organiser_type == Event.OrganiserType.CAMPUS: + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new Campus event "{event.title}" is now live!', + target_type='campus', + target_id=event.scope_org_id, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + else: + # ADMIN / COMPANY / fallback → global broadcast + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new event "{event.title}" is now available!', + target_type='global', + target_id=None, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + + return CustomResponse( + general_message=f'Event submitted: status is now "{new_status}".', + response={'id': event.id, 'status': new_status}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# CO-OWNERS +# ───────────────────────────────────────────────────────────────────────────── + +class ManageEventCoOwnerAPI(APIView): + """ + GET /events/manage//co-owners/ + POST /events/manage//co-owners/ + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Manage Event Co Owner.", + responses={200: EventCoOwnerSerializer}, + ) + def get(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + # Admins can manage co-owners for any event. + if not (RoleType.ADMIN.value in roles or can_manage_event(user_id, event)): + return CustomResponse( + general_message='Permission denied.' + ).get_failure_response() + + co_owners = list(event.connections.filter(entity_type=EventConnection.EntityType.CO_OWNER).select_related('created_by')) + user_ids = [c.entity_id for c in co_owners] + users = {str(u.id): u for u in User.objects.filter(id__in=user_ids)} + + return CustomResponse( + general_message='Co-owners retrieved.', + response=EventCoOwnerSerializer(co_owners, many=True, context={'users_map': users}).data, + ).get_success_response() + + @extend_schema( + tags=['Dashboard - Events'], + description="Create Manage Event Co Owner.", + responses={200: EventCoOwnerSerializer}, + ) + def post(self, request, event_id): + """ + Body: { "user_id": "" } + Adds a single user as a co-owner. + """ + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + # Admins can manage co-owners for any event. + if not (RoleType.ADMIN.value in roles or can_manage_event(user_id, event)): + return CustomResponse(general_message='Permission denied.').get_failure_response() + + target_user_id = request.data.get('user_id') + if not target_user_id: + return CustomResponse(general_message='user_id is required.').get_failure_response() + + if not User.objects.filter(id=target_user_id).exists(): + return CustomResponse(general_message='User not found.').get_failure_response() + + if target_user_id == event.created_by_id: + return CustomResponse( + general_message='The event creator is already the owner.' + ).get_failure_response() + + conn, created = EventConnection.objects.get_or_create( + event=event, + entity_id=target_user_id, + entity_type=EventConnection.EntityType.CO_OWNER, + defaults={ + 'id': str(uuid.uuid4()), + 'created_by_id': user_id, + 'updated_by_id': user_id, + }, + ) + + if not created: + return CustomResponse( + general_message='User is already a co-owner.' + ).get_failure_response() + + # Resolve co-owner's name for the log + co_owner_user = User.objects.filter(id=target_user_id).first() + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.CO_OWNER_ADDED, + details={ + 'name': co_owner_user.full_name if co_owner_user else target_user_id, + 'muid': co_owner_user.muid if co_owner_user else None, + 'user_id': target_user_id, + }, + ) + + return CustomResponse( + general_message='Co-owner added.', + response=EventCoOwnerSerializer(conn).data, + ).get_success_response() + + +class ManageEventCoOwnerRemoveAPI(APIView): + """ + DELETE /events/manage//co-owners// + Removes a co-owner row (co_owner_id = EventConnection.id). + """ + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events'], description="Delete Manage Event Co Owner Remove.", + responses={200: EventCoOwnerSerializer}, + ) + def delete(self, request, event_id, co_owner_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + # Admins can manage co-owners for any event. + if not (RoleType.ADMIN.value in roles or can_manage_event(user_id, event)): + return CustomResponse(general_message='Permission denied.').get_failure_response() + + conn = EventConnection.objects.filter( + id=co_owner_id, + event=event, + entity_type=EventConnection.EntityType.CO_OWNER, + ).first() + if not conn: + return CustomResponse(general_message='Co-owner record not found.').get_failure_response() + + # Resolve co-owner's name before deleting + co_owner_user = User.objects.filter(id=conn.entity_id).first() + conn.delete() + + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.CO_OWNER_REMOVED, + details={ + 'name': co_owner_user.full_name if co_owner_user else conn.entity_id, + 'muid': co_owner_user.muid if co_owner_user else None, + 'user_id': conn.entity_id, + }, + ) + return CustomResponse(general_message='Co-owner removed.').get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# COLLABORATORS +# ───────────────────────────────────────────────────────────────────────────── + +COLLAB_TYPES = [ + EventConnection.EntityType.COLLAB_IG, + EventConnection.EntityType.COLLAB_CAMPUS, + EventConnection.EntityType.COLLAB_CAMPUS_IG, + EventConnection.EntityType.COLLAB_COMPANY, +] + + +def _resolve_entity_name(entity_type, entity_id): + """ + Return a human-readable name for a collaborator entity. + Used to populate the 'name' field in log details. + """ + try: + if entity_type == EventConnection.EntityType.COLLAB_IG: + from db.task import InterestGroup + ig = InterestGroup.objects.filter(id=entity_id).first() + return ig.name if ig else entity_id + elif entity_type in ( + EventConnection.EntityType.COLLAB_CAMPUS, + EventConnection.EntityType.COLLAB_COMPANY, + ): + from db.organization import Organization + org = Organization.objects.filter(id=entity_id).first() + return org.title if org else entity_id + elif entity_type == EventConnection.EntityType.COLLAB_CAMPUS_IG: + return f'Campus-IG ({entity_id})' + except Exception: + pass + return entity_id + + +def _get_entity_leads(entity_type, entity_id): + """ + Return the User objects that are the leads/contacts of a collaborator entity. + Used to send direct invite notifications to the right people. + """ + leads = [] + try: + if entity_type == EventConnection.EntityType.COLLAB_IG: + from db.task import UserIgLink + ig_lead_links = UserIgLink.objects.filter( + ig_id=entity_id, + assignment_type=UserIgLink.AssignmentType.LEAD, + is_active=True, + ).select_related('user') + leads = [link.user for link in ig_lead_links] + elif entity_type in ( + EventConnection.EntityType.COLLAB_CAMPUS, + EventConnection.EntityType.COLLAB_COMPANY, + ): + from db.organization import UserOrganizationLink + # Only notify the org's leads (Campus Lead / Company), not every member. + lead_links = UserOrganizationLink.objects.filter( + org_id=entity_id, + verified=True, + user__user_role_link_user__role__title__in=[ + RoleType.CAMPUS_LEAD.value, + RoleType.COMPANY.value, + ], + ).select_related('user').distinct() + leads = [link.user for link in lead_links] + except Exception: + pass + return leads + + +def _caller_can_respond(conn, user_id, roles): + """ + Returns True if `user_id` is an authorised lead for the entity being invited. + Used by both Accept and Reject views. + """ + from db.organization import UserOrganizationLink + if RoleType.ADMIN.value in roles: + return True + if conn.entity_type == EventConnection.EntityType.COLLAB_IG: + from db.task import InterestGroup + ig = InterestGroup.objects.filter(id=conn.entity_id).first() + if ig: + return f'{ig.code} IGLead' in roles + elif conn.entity_type == EventConnection.EntityType.COLLAB_CAMPUS: + in_org = UserOrganizationLink.objects.filter( + user_id=user_id, org_id=conn.entity_id, verified=True + ).exists() + return in_org and RoleType.CAMPUS_LEAD.value in roles + elif conn.entity_type == EventConnection.EntityType.COLLAB_COMPANY: + in_org = UserOrganizationLink.objects.filter( + user_id=user_id, org_id=conn.entity_id, verified=True + ).exists() + return in_org and RoleType.COMPANY.value in roles + elif conn.entity_type == EventConnection.EntityType.COLLAB_CAMPUS_IG: + # entity_id is the InterestGroup id; require the matching IG campus-lead role + from db.task import InterestGroup + ig = InterestGroup.objects.filter(id=conn.entity_id).first() + if ig: + return f'{ig.code} CampusLead' in roles + return False + + +class ManageEventCollaboratorAPI(APIView): + """ + GET /events/manage//collaborators/ → all invites (incl. pending/rejected) + POST /events/manage//collaborators/ → invite a new collaborator + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Manage Event Collaborator.", + responses={200: EventCollaboratorSerializer}, + ) + def get(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + # Admins can manage collaborators for any event. + if not (RoleType.ADMIN.value in roles or can_manage_event(user_id, event)): + return CustomResponse(general_message='Permission denied.').get_failure_response() + + collabs = list(event.connections.filter(entity_type__in=COLLAB_TYPES)) + ig_ids = [c.entity_id for c in collabs if c.entity_type == EventConnection.EntityType.COLLAB_IG] + org_ids = [c.entity_id for c in collabs if c.entity_type in (EventConnection.EntityType.COLLAB_CAMPUS, EventConnection.EntityType.COLLAB_COMPANY)] + + from db.task import InterestGroup + from db.organization import Organization + igs = {str(ig.id): ig for ig in InterestGroup.objects.filter(id__in=ig_ids)} + orgs = {str(org.id): org for org in Organization.objects.filter(id__in=org_ids)} + + return CustomResponse( + general_message='Collaborators retrieved.', + response=EventCollaboratorSerializer( + collabs, many=True, + context={'request': request, 'igs_map': igs, 'orgs_map': orgs} + ).data, + ).get_success_response() + + @extend_schema( + tags=['Dashboard - Events'], + description="Create Manage Event Collaborator.", + responses={200: EventCollaboratorSerializer}, + ) + def post(self, request, event_id): + """ + Body: { + "entity_type": "collab_ig" | "collab_campus" | "collab_campus_ig" | "collab_company", + "entity_id": "", + "role_label": "Venue Partner" (optional) + } + """ + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + # Admins can manage collaborators for any event. + if not (RoleType.ADMIN.value in roles or can_manage_event(user_id, event)): + return CustomResponse(general_message='Permission denied.').get_failure_response() + + entity_type = request.data.get('entity_type') + entity_id = request.data.get('entity_id') + role_label = request.data.get('role_label', '') + + if entity_type not in [t.value for t in COLLAB_TYPES]: + return CustomResponse( + general_message=f'Invalid entity_type. Must be one of: {", ".join(t.value for t in COLLAB_TYPES)}' + ).get_failure_response() + + if not entity_id: + return CustomResponse(general_message='entity_id is required.').get_failure_response() + + conn, created = EventConnection.objects.get_or_create( + event=event, + entity_type=entity_type, + entity_id=entity_id, + defaults={ + 'id': str(uuid.uuid4()), + 'invite_status': EventConnection.InviteStatus.PENDING, + 'role_label': role_label, + 'created_by_id': user_id, + 'updated_by_id': user_id, + }, + ) + + if not created: + return CustomResponse( + general_message='This entity has already been invited.' + ).get_failure_response() + + # Resolve entity name for the log + entity_name = _resolve_entity_name(entity_type, entity_id) + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.COLLAB_INVITED, + details={ + 'entity_type': entity_type, + 'entity_id': entity_id, + 'name': entity_name, + 'role_label': role_label or None, + }, + ) + + return CustomResponse( + general_message='Collaborator invited.', + response=EventCollaboratorSerializer(conn).data, + ).get_success_response() + + +class ManageEventCollaboratorRemoveAPI(APIView): + """ + DELETE /events/manage//collaborators// + """ + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events'], description="Delete Manage Event Collaborator Remove.", + responses={200: EventCollaboratorSerializer}, + ) + def delete(self, request, event_id, collaborator_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + # Admins can manage collaborators for any event. + if not (RoleType.ADMIN.value in roles or can_manage_event(user_id, event)): + return CustomResponse(general_message='Permission denied.').get_failure_response() + + conn = EventConnection.objects.filter( + id=collaborator_id, + event=event, + entity_type__in=COLLAB_TYPES, + ).first() + if not conn: + return CustomResponse(general_message='Collaborator not found.').get_failure_response() + + entity_name = _resolve_entity_name(conn.entity_type, conn.entity_id) + conn.delete() + + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.COLLAB_REMOVED, + details={ + 'entity_type': conn.entity_type, + 'entity_id': conn.entity_id, + 'name': entity_name, + }, + ) + return CustomResponse(general_message='Collaborator removed.').get_success_response() + + +class ManageEventCollaboratorAcceptAPI(APIView): + """ + POST /events/manage//collaborators//accept/ + Called by the lead of the invited entity to accept the collaboration invite. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Create Manage Event Collaborator Accept.", + responses={200: EventCollaboratorSerializer}, + ) + def post(self, request, event_id, collaborator_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + conn = EventConnection.objects.filter( + id=collaborator_id, + event=event, + entity_type__in=COLLAB_TYPES, + ).first() + if not conn: + return CustomResponse(general_message='Collaborator invite not found.').get_failure_response() + + if conn.invite_status != EventConnection.InviteStatus.PENDING: + return CustomResponse( + general_message=f'Invite is already {conn.invite_status}.' + ).get_failure_response() + + # Verify the caller is an authorised lead for the invited entity + if not _caller_can_respond(conn, user_id, roles): + return CustomResponse( + general_message='You are not authorised to accept this invite.' + ).get_failure_response() + + conn.invite_status = EventConnection.InviteStatus.ACCEPTED + conn.responded_at = timezone.now() + conn.updated_by_id = user_id + conn.save() + + entity_name = _resolve_entity_name(conn.entity_type, conn.entity_id) + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.COLLAB_ACCEPTED, + details={ + 'entity_type': conn.entity_type, + 'entity_id': conn.entity_id, + 'name': entity_name, + }, + ) + + return CustomResponse( + general_message='Collaboration accepted.', + response=EventCollaboratorSerializer(conn).data, + ).get_success_response() + + + + +class ManageEventCollaboratorRejectAPI(APIView): + """ + POST /events/manage//collaborators//reject/ + Called by the lead of the invited entity to reject. Body: { "reason": "..." } + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Create Manage Event Collaborator Reject.", + responses={200: EventCollaboratorSerializer}, + ) + def post(self, request, event_id, collaborator_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + conn = EventConnection.objects.filter( + id=collaborator_id, + event=event, + entity_type__in=COLLAB_TYPES, + ).first() + if not conn: + return CustomResponse(general_message='Collaborator invite not found.').get_failure_response() + + if conn.invite_status != EventConnection.InviteStatus.PENDING: + return CustomResponse( + general_message=f'Invite is already {conn.invite_status}.' + ).get_failure_response() + + if not _caller_can_respond(conn, user_id, roles): + return CustomResponse( + general_message='You are not authorised to reject this invite.' + ).get_failure_response() + + conn.invite_status = EventConnection.InviteStatus.REJECTED + conn.rejection_reason = request.data.get('reason', '') + conn.responded_at = timezone.now() + conn.updated_by_id = user_id + conn.save() + + entity_name = _resolve_entity_name(conn.entity_type, conn.entity_id) + log_event_action( + event=event, + user_id=user_id, + action=EventLog.Action.COLLAB_REJECTED, + details={ + 'entity_type': conn.entity_type, + 'entity_id': conn.entity_id, + 'name': entity_name, + 'reason': conn.rejection_reason, + }, + ) + + return CustomResponse( + general_message='Collaboration invite rejected.', + response=EventCollaboratorSerializer(conn).data, + ).get_success_response() + + +class MyEventInvitesAPI(APIView): + """ + GET /events/my-invites/ + Returns all pending event collaboration invites directed at entities the current user leads. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve My Event Invites.", + responses={200: MyEventInviteSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + # If admin, fetch all pending collab invites globally + if RoleType.ADMIN.value in roles: + invites = EventConnection.objects.filter( + invite_status=EventConnection.InviteStatus.PENDING, + entity_type__in=COLLAB_TYPES + ).select_related('event').order_by('-created_at') + invites_list = list(invites) + ig_ids = [inv.entity_id for inv in invites_list if inv.entity_type == EventConnection.EntityType.COLLAB_IG] + org_ids = [inv.entity_id for inv in invites_list if inv.entity_type in (EventConnection.EntityType.COLLAB_CAMPUS, EventConnection.EntityType.COLLAB_COMPANY)] + + from db.task import InterestGroup + from db.organization import Organization + igs = {str(ig.id): ig for ig in InterestGroup.objects.filter(id__in=ig_ids)} + orgs = {str(org.id): org for org in Organization.objects.filter(id__in=org_ids)} + + serializer = MyEventInviteSerializer( + invites_list, many=True, context={'request': request, 'igs_map': igs, 'orgs_map': orgs}, + ) + return CustomResponse( + general_message='Global pending invites retrieved.', + response=serializer.data + ).get_success_response() + + auth_ig_codes = [] + is_campus_lead = RoleType.CAMPUS_LEAD.value in roles + is_company = RoleType.COMPANY.value in roles + has_any_campus_lead_role = False + + for role in roles: + if role.endswith(' IGLead'): + ig_code = role.replace(' IGLead', '') + auth_ig_codes.append(ig_code) + if role.endswith(' CampusLead') or role == RoleType.CAMPUS_LEAD.value: + has_any_campus_lead_role = True + + from db.task import InterestGroup + from db.organization import UserOrganizationLink + from django.db.models import Q + + query = Q() + + if auth_ig_codes: + ig_ids = InterestGroup.objects.filter(code__in=auth_ig_codes).values_list('id', flat=True) + query |= Q(entity_type=EventConnection.EntityType.COLLAB_IG, entity_id__in=ig_ids) + + if is_campus_lead or is_company: + org_ids = UserOrganizationLink.objects.filter( + user_id=user_id, verified=True + ).values_list('org_id', flat=True) + query |= Q( + entity_type__in=[EventConnection.EntityType.COLLAB_CAMPUS, EventConnection.EntityType.COLLAB_COMPANY], + entity_id__in=org_ids + ) + + if has_any_campus_lead_role: + # Scope campus-IG invites to the specific IGs the user is a campus + # lead for (entity_id on a campus_ig invite is the InterestGroup id). + campus_ig_codes = [ + r.replace(' CampusLead', '') for r in roles if r.endswith(' CampusLead') + ] + if campus_ig_codes: + ci_ig_ids = InterestGroup.objects.filter( + code__in=campus_ig_codes + ).values_list('id', flat=True) + query |= Q( + entity_type=EventConnection.EntityType.COLLAB_CAMPUS_IG, + entity_id__in=list(ci_ig_ids), + ) + + if not query: + return CustomResponse( + general_message='Pending invites retrieved.', + response=[] + ).get_success_response() + + invites = EventConnection.objects.filter( + query, + invite_status=EventConnection.InviteStatus.PENDING, + ).select_related('event').order_by('-created_at') + + invites_list = list(invites) + ig_ids = [inv.entity_id for inv in invites_list if inv.entity_type == EventConnection.EntityType.COLLAB_IG] + org_ids = [inv.entity_id for inv in invites_list if inv.entity_type in (EventConnection.EntityType.COLLAB_CAMPUS, EventConnection.EntityType.COLLAB_COMPANY)] + + from db.task import InterestGroup + from db.organization import Organization + igs = {str(ig.id): ig for ig in InterestGroup.objects.filter(id__in=ig_ids)} + orgs = {str(org.id): org for org in Organization.objects.filter(id__in=org_ids)} + + serializer = MyEventInviteSerializer( + invites_list, many=True, context={'request': request, 'igs_map': igs, 'orgs_map': orgs}, + ) + return CustomResponse( + general_message='Pending invites retrieved.', + response=serializer.data + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# MENTOR APPROVAL ENDPOINTS +# ───────────────────────────────────────────────────────────────────────────── + +class MentorEventApproveAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events']) + def post(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + if RoleType.MENTOR.value not in roles: + return CustomResponse(general_message='Mentor role required.').get_failure_response() + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if event.status != Event.Status.PENDING_MENTOR_APPROVAL: + return CustomResponse(general_message='Event is not pending mentor approval.').get_failure_response() + + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import get_mentor_scopes, has_scope + scopes = get_mentor_scopes(user_id) + if not scopes: + return CustomResponse(general_message='Active mentor profile not found.').get_failure_response() + + if event.organiser_type == Event.OrganiserType.CAMPUS_IG: + if not has_scope(user_id, MentorScopeGrant.ScopeType.CAMPUS_MENTOR, event.scope_org_id): + return CustomResponse(general_message='You are not authorized to approve this Campus IG event.').get_failure_response() + new_status = Event.Status.PENDING_CAMPUS_APPROVAL + elif event.organiser_type == Event.OrganiserType.GLOBAL_IG: + if not any(st == MentorScopeGrant.ScopeType.IG_MENTOR for st, _ in scopes): + return CustomResponse(general_message='You are not authorized to approve this Global IG event.').get_failure_response() + from db.task import UserIgLink + is_assigned = UserIgLink.objects.filter( + user_id=user_id, + ig_id=event.organiser_ig_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True + ).exists() + if not is_assigned: + return CustomResponse(general_message='You are not a mentor for this Interest Group.').get_failure_response() + new_status = Event.Status.PENDING_APPROVAL + else: + return CustomResponse(general_message='Event type not supported for mentor approval.').get_failure_response() + + event.status = new_status + event.updated_by_id = user_id + event.save() + + log_event_action(event=event, user_id=user_id, action=EventLog.Action.APPROVED, changes={'Status': {'from': Event.Status.PENDING_MENTOR_APPROVAL, 'to': new_status}}) + + # Notify the event creator + actor = User.objects.filter(id=user_id).first() + creator = event.created_by + if creator and actor: + NotificationUtils.insert_notification( + user=creator, + title='Event Approved by Mentor', + description=f'Your event "{event.title}" has been approved by a mentor.', + button=None, + url=None, + created_by=actor, + ) + + return CustomResponse(general_message='Event approved successfully.').get_success_response() + + +class MentorEventRejectAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events']) + def post(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + if RoleType.MENTOR.value not in roles: + return CustomResponse(general_message='Mentor role required.').get_failure_response() + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if event.status != Event.Status.PENDING_MENTOR_APPROVAL: + return CustomResponse(general_message='Event is not pending mentor approval.').get_failure_response() + + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import get_mentor_scopes, has_scope + scopes = get_mentor_scopes(user_id) + if not scopes: + return CustomResponse(general_message='Active mentor profile not found.').get_failure_response() + + if event.organiser_type == Event.OrganiserType.CAMPUS_IG: + if not has_scope(user_id, MentorScopeGrant.ScopeType.CAMPUS_MENTOR, event.scope_org_id): + return CustomResponse(general_message='You are not authorized to reject this Campus IG event.').get_failure_response() + elif event.organiser_type == Event.OrganiserType.GLOBAL_IG: + if not any(st == MentorScopeGrant.ScopeType.IG_MENTOR for st, _ in scopes): + return CustomResponse(general_message='You are not authorized to reject this Global IG event.').get_failure_response() + from db.task import UserIgLink + is_assigned = UserIgLink.objects.filter( + user_id=user_id, + ig_id=event.organiser_ig_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True + ).exists() + if not is_assigned: + return CustomResponse(general_message='You are not a mentor for this Interest Group.').get_failure_response() + + reason = request.data.get('reason', '').strip() + if not reason: + return CustomResponse(general_message='A rejection reason is required.').get_failure_response() + + old_status = event.status + event.status = Event.Status.REJECTED + event.updated_by_id = user_id + event.save() + + log_event_action(event=event, user_id=user_id, action=EventLog.Action.REJECTED, changes={'Status': {'from': old_status, 'to': Event.Status.REJECTED}}, details={'reason': reason}) + + # Notify the event creator + actor = User.objects.filter(id=user_id).first() + creator = event.created_by + if creator and actor: + NotificationUtils.insert_notification( + user=creator, + title='Event Rejected by Mentor', + description=f'Your event "{event.title}" was rejected by a mentor. Reason: {reason}', + button=None, + url=None, + created_by=actor, + ) + + return CustomResponse(general_message='Event rejected successfully.').get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# CAMPUS APPROVAL ENDPOINTS +# ───────────────────────────────────────────────────────────────────────────── + +class CampusEventApproveAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events']) + def post(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + # Must be campus lead, enabler, or higher + if not set(roles) & {RoleType.CAMPUS_LEAD.value, RoleType.ZONAL_CAMPUS_LEAD.value, RoleType.DISTRICT_CAMPUS_LEAD.value, RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value, RoleType.ADMIN.value}: + return CustomResponse(general_message='Campus lead or enabler role required.').get_failure_response() + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if event.status != Event.Status.PENDING_CAMPUS_APPROVAL: + return CustomResponse(general_message='Event is not pending campus approval.').get_failure_response() + + # Verify campus lead matches the campus of the event (event.scope_org_id) + if RoleType.ADMIN.value not in roles: + from db.organization import UserOrganizationLink + is_member = UserOrganizationLink.objects.filter( + user_id=user_id, + org_id=event.scope_org_id + ).exists() + if not is_member: + return CustomResponse(general_message='You are not authorized to approve events for this campus.').get_failure_response() + + # Campus events scoped to their own campus publish on campus approval + # (no admin step); campus IG events and wider-scoped events continue + # to the next approval stage. + if event.organiser_type == Event.OrganiserType.CAMPUS and event.scope == Event.Scope.CAMPUS: + new_status = Event.Status.PUBLISHED + else: + new_status = Event.Status.PENDING_APPROVAL + + event.status = new_status + event.updated_by_id = user_id + event.save() + + log_event_action(event=event, user_id=user_id, action=EventLog.Action.APPROVED, changes={'Status': {'from': Event.Status.PENDING_CAMPUS_APPROVAL, 'to': new_status}}) + + # Notify the event creator + actor = User.objects.filter(id=user_id).first() + creator = event.created_by + if creator and actor: + if new_status == Event.Status.PUBLISHED: + NotificationUtils.insert_notification( + user=creator, + title='Event Published', + description=f'Your event "{event.title}" was approved by the campus and is now live!', + button='View', + url=f'/events/{event.id}/', + created_by=actor, + ) + else: + NotificationUtils.insert_notification( + user=creator, + title='Event Approved by Campus', + description=f'Your event "{event.title}" has been approved by the campus lead.', + button=None, + url=None, + created_by=actor, + ) + + # Fire audience broadcast when the event reaches PUBLISHED + if new_status == Event.Status.PUBLISHED and actor: + BroadcastUtils.create_broadcast( + title='New Event Published', + description=f'A new Campus event "{event.title}" is now live!', + target_type='campus', + target_id=event.scope_org_id, + created_by=actor, + expiry_key='event_published', + url=f'/events/{event.id}/', + ) + + return CustomResponse(general_message='Event approved successfully.').get_success_response() + + +class CampusEventRejectAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events']) + def post(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + if not set(roles) & {RoleType.CAMPUS_LEAD.value, RoleType.ZONAL_CAMPUS_LEAD.value, RoleType.DISTRICT_CAMPUS_LEAD.value, RoleType.ENABLER.value, RoleType.LEAD_ENABLER.value, RoleType.ADMIN.value}: + return CustomResponse(general_message='Campus lead or enabler role required.').get_failure_response() + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if event.status != Event.Status.PENDING_CAMPUS_APPROVAL: + return CustomResponse(general_message='Event is not pending campus approval.').get_failure_response() + + # Verify campus lead matches the campus of the event (event.scope_org_id) + if RoleType.ADMIN.value not in roles: + from db.organization import UserOrganizationLink + is_member = UserOrganizationLink.objects.filter( + user_id=user_id, + org_id=event.scope_org_id + ).exists() + if not is_member: + return CustomResponse(general_message='You are not authorized to reject events for this campus.').get_failure_response() + + reason = request.data.get('reason', '').strip() + if not reason: + return CustomResponse(general_message='A rejection reason is required.').get_failure_response() + + old_status = event.status + event.status = Event.Status.REJECTED + event.updated_by_id = user_id + event.save() + + log_event_action(event=event, user_id=user_id, action=EventLog.Action.REJECTED, changes={'Status': {'from': old_status, 'to': Event.Status.REJECTED}}, details={'reason': reason}) + + # Notify the event creator + actor = User.objects.filter(id=user_id).first() + creator = event.created_by + if creator and actor: + NotificationUtils.insert_notification( + user=creator, + title='Event Rejected by Campus', + description=f'Your event "{event.title}" was rejected by the campus lead. Reason: {reason}', + button=None, + url=None, + created_by=actor, + ) + + return CustomResponse(general_message='Event rejected successfully.').get_success_response() diff --git a/api/dashboard/events/meta_views.py b/api/dashboard/events/meta_views.py new file mode 100644 index 000000000..95eeae691 --- /dev/null +++ b/api/dashboard/events/meta_views.py @@ -0,0 +1,308 @@ +""" +Meta API views — helpers to populate form dropdowns. +""" +from rest_framework.views import APIView + +from db.task import InterestGroup, Category +from db.events import Event +from db.organization import Organization, UserOrganizationLink +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.types import RoleType +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s + + +class EventCategoriesAPI(APIView): + """ + GET /events/meta/categories/ + Lists all valid event categories for use in create/edit form dropdowns. + No authentication required. + """ + + @extend_schema(tags=['Dashboard - Events'], description="Retrieve Event Categories.", + responses={200: inline_serializer( + name='EventCategoryItem', + fields={ + 'id': s.CharField(), + 'name': s.CharField(), + 'description': s.CharField(allow_null=True), + }, + many=True, + )}, + ) + def get(self, request): + categories = Category.objects.filter( + entity_type=Category.EntityType.EVENT, + ).values('id', 'name', 'description').order_by('name') + + return CustomResponse( + general_message='Event categories retrieved.', + response=list(categories), + ).get_success_response() + + +class OrganizerOptionsAPI(APIView): + """ + GET /events/meta/organizer-options/ + Returns all organiser contexts the caller is authorised to create events as. + Used to populate the "Create as…" dropdown in the event creation form. + """ + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events'], description="Retrieve Organizer Options.", + responses={200: inline_serializer( + name='EventOrganizerOptions', + fields={ + 'can_create_as_ig': s.ListField(child=s.DictField()), + 'can_create_as_campus_ig': s.ListField(child=s.DictField()), + 'can_create_as_campus': s.ListField(child=s.DictField()), + 'can_create_as_company': s.ListField(child=s.DictField()), + 'can_create_as_admin': s.BooleanField(), + 'campus_context': s.DictField(allow_null=True), + }, + )}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + options = { + 'can_create_as_ig': [], # IGs the user leads globally + 'can_create_as_campus_ig': [], # Campus IG chapters the user leads (code present in roles) + 'can_create_as_campus': [], # Campus orgs the user leads + 'can_create_as_company': [], # Companies the user belongs to with Company role + 'can_create_as_admin': False, # True if user is admin + } + + # Admin, Enabler, and Lead Enabler can create events as muLearn (admin organiser) + if RoleType.ADMIN.value in roles or RoleType.ENABLER.value in roles or RoleType.LEAD_ENABLER.value in roles: + options['can_create_as_admin'] = True + + # Global IG leads: roles like "WEBDEV IGLead" + ig_lead_codes = [ + r.replace(' IGLead', '') + for r in roles if r.endswith(' IGLead') + ] + if ig_lead_codes: + igs = InterestGroup.objects.filter(code__in=ig_lead_codes).values( + 'id', 'name', 'icon', 'code' + ) + options['can_create_as_ig'] = list(igs) + + # Campus IG leads: roles like "WEBDEV CampusLead" + ci_lead_codes = [ + r.replace(' CampusLead', '') + for r in roles if r.endswith(' CampusLead') + ] + if ci_lead_codes: + igs = InterestGroup.objects.filter(code__in=ci_lead_codes).values( + 'id', 'name', 'icon', 'code' + ) + options['can_create_as_campus_ig'] = list(igs) + + # Campus Lead or Zonal/District leads can create campus events + campus_lead_roles = { + RoleType.CAMPUS_LEAD.value, + RoleType.ZONAL_CAMPUS_LEAD.value, + RoleType.DISTRICT_CAMPUS_LEAD.value, + } + if campus_lead_roles.intersection(set(roles)): + user_orgs = UserOrganizationLink.objects.filter( + user_id=user_id, verified=True + ).select_related('org') + for link in user_orgs: + if link.org.org_type in ('College', 'School'): + options['can_create_as_campus'].append({ + 'id': link.org.id, + 'title': link.org.title, + 'org_type': link.org.org_type, + }) + + # Company: user with Company role in a company org or UserMentor with COMPANY_MENTOR + company_options = {} + if RoleType.COMPANY.value in roles: + user_orgs = UserOrganizationLink.objects.filter( + user_id=user_id, verified=True + ).select_related('org') + for link in user_orgs: + if link.org.org_type == 'Company': + company_options[link.org.id] = { + 'id': link.org.id, + 'title': link.org.title, + } + + if RoleType.MENTOR.value in roles: + from db.user import MentorScopeGrant + from db.task import UserIgLink + from db.organization import Organization + from api.dashboard.mentor.dash_mentor_helper import get_scope_ids + + # Company Mentors → can create Company events for their org + company_org_ids = get_scope_ids(user_id, MentorScopeGrant.ScopeType.COMPANY_MENTOR) + for org in Organization.objects.filter(id__in=company_org_ids): + company_options[org.id] = { + 'id': org.id, + 'title': org.title, + } + + # IG Mentors → can create Global IG events for their assigned IGs + ig_mentor = bool(get_scope_ids(user_id, MentorScopeGrant.ScopeType.IG_MENTOR)) + if ig_mentor: + mentored_ig_ids = list( + UserIgLink.objects.filter( + user_id=user_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).values_list('ig_id', flat=True) + ) + if mentored_ig_ids: + mentored_igs = InterestGroup.objects.filter( + id__in=mentored_ig_ids + ).values('id', 'name', 'icon', 'code') + # Merge with existing ig options (avoid duplicates) + existing_ig_ids = {ig['id'] for ig in options['can_create_as_ig']} + for ig in mentored_igs: + if ig['id'] not in existing_ig_ids: + options['can_create_as_ig'].append(ig) + + # Campus Mentors → can create Campus IG events scoped to their campus + campus_org_ids = get_scope_ids(user_id, MentorScopeGrant.ScopeType.CAMPUS_MENTOR) + if campus_org_ids: + # Only IGs with an active chapter at one of the mentor's + # campuses qualify as campus_ig organiser options. + campus_igs = InterestGroup.objects.filter( + campus_ig_chapter_ig__org_id__in=campus_org_ids, + campus_ig_chapter_ig__is_active=True, + ).distinct().values('id', 'name', 'icon', 'code') + existing_ci_ids = {ig['id'] for ig in options['can_create_as_campus_ig']} + for ig in campus_igs: + if ig['id'] not in existing_ci_ids: + options['can_create_as_campus_ig'].append(ig) + + options['can_create_as_company'] = list(company_options.values()) + + # Resolve the creator's owning campus (for Campus-IG labelling on the FE). + # Function-local import avoids a circular import with manage_views. + from api.dashboard.events.manage_views import _resolve_creator_campus_id + campus_id = _resolve_creator_campus_id(user_id) + campus_context = None + if campus_id: + from db.organization import Organization + campus_org = Organization.objects.filter(id=campus_id).first() + if campus_org: + campus_context = {'id': campus_org.id, 'title': campus_org.title} + options['campus_context'] = campus_context + + return CustomResponse( + general_message='Organiser options retrieved.', + response=options, + ).get_success_response() + + +class CollaborationTargetsAPI(APIView): + """ + GET /events/meta/collaboration-targets/?search=&type= + Live search for entities that can be invited as collaborators. + Used to power the collaborator search input. + + Query params: + - search (str) : partial name match + - type (str) : ig | campus | campus_ig | company (optional filter) + - campus_id (str) : restrict campus_ig results to IGs with an active + chapter at this campus (used by the scope pickers; + collaborator search omits it and gets all IGs) + """ + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events'], description="Retrieve Collaboration Targets.", + responses={200: inline_serializer( + name='EventCollaborationTargets', + fields={ + 'ig': s.ListField(child=s.DictField()), + 'campus': s.ListField(child=s.DictField()), + 'company': s.ListField(child=s.DictField()), + 'campus_ig': s.ListField(child=s.DictField()), + }, + )}, + ) + def get(self, request): + search = request.query_params.get('search', '').strip() + filter_type = request.query_params.get('type', '').strip() + + results = { + 'ig': [], + 'campus': [], + 'company': [], + 'campus_ig': [], + } + + campus_id = request.query_params.get('campus_id', '').strip() + + if not filter_type or filter_type in ('ig', 'campus_ig'): + qs = InterestGroup.objects.all() + if search: + qs = qs.filter(name__icontains=search) + if not filter_type or filter_type == 'ig': + results['ig'] = list(qs.values('id', 'name', 'icon', 'code')[:20]) + if not filter_type or filter_type == 'campus_ig': + # campus_ig entries are identified by their IG; return as campus_ig key. + # When a campus context is given, only IGs with an active chapter + # at that campus qualify — a campus with no chapters gets none. + ci_qs = qs + if campus_id: + ci_qs = ci_qs.filter( + campus_ig_chapter_ig__org_id=campus_id, + campus_ig_chapter_ig__is_active=True, + ).distinct() + results['campus_ig'] = list(ci_qs.values('id', 'name', 'icon', 'code')[:20]) + + if not filter_type or filter_type == 'campus': + qs = Organization.objects.filter(org_type='College') + if search: + qs = qs.filter(title__icontains=search) + results['campus'] = list(qs.values('id', 'title', 'org_type')[:20]) + + if not filter_type or filter_type == 'company': + qs = Organization.objects.filter(org_type='Company') + if search: + qs = qs.filter(title__icontains=search) + results['company'] = list(qs.values('id', 'title', 'org_type')[:20]) + + return CustomResponse( + general_message='Collaboration targets retrieved.', + response=results, + ).get_success_response() + + +class EventTypesScopesAPI(APIView): + """ + GET /events/meta/event-type-scope/ + Lists all valid event types and event scopes. + No authentication required. + """ + + @extend_schema(tags=['Dashboard - Events'], description="Retrieve Event Types and Scopes.", + responses={200: inline_serializer( + name='EventTypesScopesResponse', + fields={ + 'event_type': s.ListField(child=s.CharField()), + 'event_scope': s.ListField(child=s.CharField()), + } + )}, + ) + def get(self, request): + return CustomResponse( + general_message='Event types and scopes retrieved.', + response={ + "event_type": [ + {"value": v, "label": l} + for v, l in zip(Event.EventType.values, Event.EventType.labels) + ], + "event_scope": [ + {"value": v, "label": l} + for v, l in zip(Event.EventScope.values, Event.EventScope.labels) + ], + }, + ).get_success_response() + diff --git a/api/dashboard/events/public_views.py b/api/dashboard/events/public_views.py new file mode 100644 index 000000000..7ec484269 --- /dev/null +++ b/api/dashboard/events/public_views.py @@ -0,0 +1,513 @@ +""" +Public Events API views — no special role required, but some +endpoints benefit from an authenticated user context. +""" +import uuid + +from django.db.models import F, Q, Exists, OuterRef +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated + +from db.events import Event, EventInterest +from db.task import Wallet, UserIgLink, TaskList +from db.organization import UserOrganizationLink +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.utils import CommonUtils + +from datetime import datetime +from django.utils import timezone + +from .serializers import ( + EventListItemSerializer, + EventDetailSerializer, + get_live_events, + EventCalendarItemSerializer, +) +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s +from api.dashboard.task.dash_task_serializer import TaskListPublicSerializer + + + +def _get_viewer_id(request): + """Safely extract user_id; returns None if unauthenticated.""" + try: + return JWTUtils.fetch_user_id(request) + except Exception: + return None + + +def _build_scope_filter(user_id): + """ + Returns a list of Q-compatible kwargs to use in an OR-filter + so we show only events accessible to this viewer. + + Visibility rules: + - scope=global → always visible + - scope=campus → user must be in scope_org + - scope=ig → user must be in scope_ig + - scope=campus_ig → user must be in scope_org AND scope_ig + - scope=company → user must be in organiser_org (company) + """ + # Always show global events + q = Q(scope=Event.Scope.GLOBAL) + + if not user_id: + return q # Unauthenticated: global only + + # Campus scope: user's organisation matches scope_org + user_org_ids = list( + UserOrganizationLink.objects.filter(user_id=user_id, verified=True) + .values_list('org_id', flat=True) + ) + if user_org_ids: + q |= Q(scope=Event.Scope.CAMPUS, scope_org_id__in=user_org_ids) + + # IG scope: user's IGs match scope_ig + user_ig_ids = list( + UserIgLink.objects.filter(user_id=user_id) + .values_list('ig_id', flat=True) + ) + if user_ig_ids: + q |= Q(scope=Event.Scope.IG, scope_ig_id__in=user_ig_ids) + + # Campus-IG scope: user in org AND in ig + if user_org_ids and user_ig_ids: + q |= Q(scope=Event.Scope.CAMPUS_IG, + scope_org_id__in=user_org_ids, + scope_ig_id__in=user_ig_ids) + + # Company scope: event is scoped to a company the user belongs to + if user_org_ids: + q |= Q(scope=Event.Scope.COMPANY, scope_org_id__in=user_org_ids) + + return q + + +def _public_events_queryset(request, *, featured_only=False): + """ + Base queryset for public event lists: scope visibility, published/ongoing, + optional filters. When ``featured_only`` is True, only ``is_featured`` rows + are returned (used by /events/featured/ and /events/is-featured/). + """ + user_id = _get_viewer_id(request) + + scope_filter = _build_scope_filter(user_id) + events = get_live_events().select_related('category', 'organiser_ig', 'organiser_org').filter(scope_filter) + + events = events.filter( + status__in=[Event.Status.PUBLISHED, Event.Status.ONGOING] + ) + + if featured_only: + events = events.filter(is_featured=True) + + params = request.query_params + + if event_type := params.get('event_type'): + events = events.filter(event_type=event_type) + if ig_id := params.get('ig_id'): + events = events.filter(scope_ig_id=ig_id) + if campus_id := params.get('campus_id'): + events = events.filter(scope_org_id=campus_id) + if cluster := params.get('cluster'): + events = events.filter(organiser_ig__category=cluster) + if not featured_only: + if is_featured := params.get('is_featured'): + events = events.filter(is_featured=is_featured.lower() == 'true') + if start_date := params.get('start_date'): + events = events.filter(start_datetime__date__gte=start_date) + if end_date := params.get('end_date'): + events = events.filter(end_datetime__date__lte=end_date) + if tags := params.get('tags'): + events = events.filter(tags__icontains=tags) + + if params.get('eligible_only') == 'true' and user_id: + try: + karma = Wallet.objects.filter(user_id=user_id).values_list('karma', flat=True).first() or 0 + except Exception: + karma = 0 + events = events.filter( + Q(min_karma__isnull=True) | Q(min_karma__lte=karma) + ) + + if user_id: + events = events.annotate( + viewer_interested=Exists( + EventInterest.objects.filter(event=OuterRef('pk'), user_id=user_id) + ) + ) + + return events, user_id + + +_PUBLIC_EVENT_SORT_FIELDS = { + 'start_datetime': 'start_datetime', + '-start_datetime': '-start_datetime', + 'interest_count': '-interest_count', + 'created_at': 'created_at', +} + + +class EventListAPI(APIView): + """ + GET /events/ + Public paginated list of events. + Applies scope visibility rules, then optional filters from query params. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Event List.", + responses={200: EventListItemSerializer}, + ) + def get(self, request): + events, user_id = _public_events_queryset(request, featured_only=False) + + paginated = CommonUtils.get_paginated_queryset( + events, request, + search_fields=['title', 'description', 'venue_city'], + sort_fields=_PUBLIC_EVENT_SORT_FIELDS, + ) + + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': user_id, 'request': request}, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class EventFeaturedAPI(APIView): + """ + GET /events/featured/ and GET /events/is-featured/ + + Featured events the viewer may see (same scope rules as GET /events/), + ``is_featured=True``, status published or ongoing. Paginated; optional JWT + for ``viewer_interest_status``. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Event Featured.", + responses={200: EventListItemSerializer}, + ) + def get(self, request): + events, user_id = _public_events_queryset(request, featured_only=True) + + paginated = CommonUtils.get_paginated_queryset( + events, request, + search_fields=['title', 'description', 'venue_city'], + sort_fields=_PUBLIC_EVENT_SORT_FIELDS, + ) + + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': user_id, 'request': request}, + ) + return CustomResponse( + general_message='Featured events retrieved.', + ).paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class EventDetailAPI(APIView): + """ + GET /events// + Full event detail. Scope-checked; draft/pending visible only to organiser/admin. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Event Detail.", + responses={200: EventDetailSerializer}, + ) + def get(self, request, event_id): + user_id = _get_viewer_id(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse( + general_message='Event not found.' + ).get_failure_response() + + # Draft / pending events: only organiser and admins + non_public_statuses = [ + Event.Status.DRAFT, + Event.Status.PENDING_CAMPUS_APPROVAL, + Event.Status.PENDING_APPROVAL, + Event.Status.PENDING_MENTOR_APPROVAL, + ] + if event.status in non_public_statuses: + if not user_id: + return CustomResponse( + general_message='Event not found.' + ).get_failure_response() + try: + roles = JWTUtils.fetch_role(request) + from utils.types import RoleType + from .serializers import can_manage_event + is_admin = RoleType.ADMIN.value in roles + if not is_admin and not can_manage_event(user_id, event): + return CustomResponse( + general_message='Event not found.' + ).get_failure_response() + except Exception: + return CustomResponse( + general_message='Event not found.' + ).get_failure_response() + + serializer = EventDetailSerializer( + event, + context={'user_id': user_id, 'request': request}, + ) + return CustomResponse( + general_message='Event detail retrieved.', + response=serializer.data, + ).get_success_response() + + +class EventInterestAPI(APIView): + """ + POST /events//interest/ → Express "I'm Going" + DELETE /events//interest/ → Remove interest + """ + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Events'], description="Create Event Interest.", + responses={200: inline_serializer( + name='EventInterestCreateResponse', + fields={ + 'event_id': s.CharField(), + 'user_id': s.CharField(), + 'status': s.CharField(), + }, + )}, + ) + def post(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return CustomResponse(general_message='Event not found.').get_failure_response() + + if event.status not in (Event.Status.PUBLISHED, Event.Status.ONGOING): + return CustomResponse( + general_message='Cannot express interest in an unpublished event.' + ).get_failure_response() + + # Check min_karma gate + if event.min_karma: + try: + karma = Wallet.objects.filter(user_id=user_id).values_list('karma', flat=True).first() or 0 + except Exception: + karma = 0 + if karma < event.min_karma: + return CustomResponse( + general_message=f'You need {event.min_karma:,} karma to access this event.' + ).get_failure_response() + + _, created = EventInterest.objects.get_or_create( + event=event, + user_id=user_id, + defaults={'id': str(uuid.uuid4())}, + ) + + if created: + Event.objects.filter(id=event_id).update(interest_count=F('interest_count') + 1) + msg = "You're now marked as interested in this event." + else: + msg = 'You have already expressed interest in this event.' + + return CustomResponse( + general_message=msg, + response={'event_id': event_id, 'user_id': user_id, 'status': 'interested'}, + ).get_success_response() + + @extend_schema(tags=['Dashboard - Events'], description="Delete Event Interest.", + responses={200: OpenApiResponse(description="Interest removed successfully.")}, + ) + def delete(self, request, event_id): + user_id = JWTUtils.fetch_user_id(request) + + interest = EventInterest.objects.filter(event_id=event_id, user_id=user_id).first() + if not interest: + return CustomResponse( + general_message='You have not expressed interest in this event.' + ).get_failure_response() + + interest.delete() + Event.objects.filter(id=event_id).update(interest_count=F('interest_count') - 1) + + return CustomResponse( + general_message='Your interest has been removed.' + ).get_success_response() + + +class EventCalendarAPI(APIView): + """ + GET /events/calendar/ + Returns a list of events overlapping the requested date range, formatted for frontend calendar display. + Query Params: + - start_date (YYYY-MM-DD, mandatory) + - end_date (YYYY-MM-DD, mandatory) + """ + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Event Calendar.", + responses={200: EventCalendarItemSerializer}, + ) + def get(self, request): + start_date = request.query_params.get('start_date') + end_date = request.query_params.get('end_date') + + if not start_date or not end_date: + return CustomResponse( + general_message='Both start_date and end_date query parameters are required.' + ).get_failure_response() + + try: + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + + from datetime import timedelta + # Add 1 day to end_dt to make it inclusive of the requested end date + end_dt = end_dt + timedelta(days=1) + + start_dt = timezone.make_aware(start_dt, timezone.utc) + end_dt = timezone.make_aware(end_dt, timezone.utc) + except ValueError: + return CustomResponse( + general_message='Invalid date format. Use YYYY-MM-DD.' + ).get_failure_response() + + if start_dt >= end_dt: + return CustomResponse( + general_message='start_date must be before or equal to end_date.' + ).get_failure_response() + + if (end_dt - start_dt).days > 94: + return CustomResponse( + general_message='Date range must not exceed 93 days.' + ).get_failure_response() + + user_id = _get_viewer_id(request) + scope_filter = _build_scope_filter(user_id) + + events = ( + get_live_events() + .select_related('category', 'organiser_ig', 'organiser_org') + .filter(scope_filter) + .filter( + status__in=[Event.Status.PUBLISHED, Event.Status.ONGOING, Event.Status.COMPLETED], + start_datetime__lt=end_dt, + end_datetime__gt=start_dt, + ) + .order_by('start_datetime', 'end_datetime') + ) + + serializer = EventCalendarItemSerializer(events, many=True) + return CustomResponse(response=serializer.data).get_success_response() + + +class EventTaskPublicListAPI(APIView): + """ + GET /events/tasks/ + + Scope-aware, authentication-optional API that returns tasks linked to + events the caller is allowed to see. + + Visibility rules (mirror the event list scope rules): + - Unauthenticated users → tasks from GLOBAL-scoped events only. + - Authenticated users → tasks from GLOBAL events + any campus/IG/ + campus_ig/company-scoped event the user + belongs to (via UserOrganizationLink / + UserIgLink). + + Response format matches TaskListPublicSerializer (same as the existing + TaskPublicListAPI used in MuJourney). + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description=( + "Scope-aware list of tasks for events the caller can access. " + "Unauthenticated callers receive only global-event tasks." + ), + responses={200: TaskListPublicSerializer(many=True)}, + ) + def get(self, request): + user_id = _get_viewer_id(request) + + # ------------------------------------------------------------------ + # 1. Determine which events the caller may see (scope-gate). + # _build_scope_filter returns a Q object that encodes: + # scope=GLOBAL → always + # scope=CAMPUS & scope_org in user's orgs → if logged in + # scope=IG & scope_ig in user's IGs → if logged in + # scope=CAMPUS_IG & both org + IG match → if logged in + # scope=COMPANY & scope_org in user's orgs → if logged in + # ------------------------------------------------------------------ + scope_filter = _build_scope_filter(user_id) + + accessible_event_ids = ( + get_live_events() + .filter( + scope_filter, + status__in=[Event.Status.PUBLISHED, Event.Status.ONGOING], + ) + .values_list('id', flat=True) + ) + + # ------------------------------------------------------------------ + # 2. Fetch tasks that are: + # - linked to one of the accessible events (event_fk is set) + # - active (approved and live) + # ------------------------------------------------------------------ + tasks = TaskList.objects.select_related( + 'channel', 'type', 'level', 'ig', 'org', 'event_fk', + ).filter( + active=True, + event_fk_id__in=accessible_event_ids, + ) + + paginated = CommonUtils.get_paginated_queryset( + tasks, + request, + search_fields=[ + 'hashtag', + 'title', + 'description', + 'karma', + 'channel__name', + 'type__title', + 'ig__name', + 'event_fk__title', + ], + sort_fields={ + 'hashtag': 'hashtag', + 'title': 'title', + 'karma': 'karma', + 'type': 'type__title', + 'ig': 'ig__name', + 'created_at': 'created_at', + }, + ) + + serializer = TaskListPublicSerializer( + paginated['queryset'], many=True + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) diff --git a/api/dashboard/events/scoped_views.py b/api/dashboard/events/scoped_views.py new file mode 100644 index 000000000..a9adfe622 --- /dev/null +++ b/api/dashboard/events/scoped_views.py @@ -0,0 +1,229 @@ +""" +Scoped Event Feed views. +Each view returns published events filtered to a specific entity scope. +""" +from rest_framework.views import APIView + +from db.events import Event +from db.task import InterestGroup +from db.organization import Organization +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.utils import CommonUtils + +from .serializers import EventListItemSerializer, get_live_events +from drf_spectacular.utils import extend_schema + + +def _viewer_id(request): + try: + return JWTUtils.fetch_user_id(request) + except Exception: + return None + + +PUBLISHED_STATUSES = [Event.Status.PUBLISHED, Event.Status.ONGOING] + + +class IGEventListAPI(APIView): + """ + GET /events/ig// + All published events organised by or scoped to a specific IG. + Includes both global-IG events and all campus chapter events for that IG. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve I G Event List.", + responses={200: EventListItemSerializer}, + ) + def get(self, request, ig_id): + ig = InterestGroup.objects.filter(id=ig_id.strip()).first() + if not ig: + return CustomResponse(general_message='Interest Group not found.').get_failure_response() + + from django.db.models import Q + events = get_live_events().select_related('category', 'organiser_ig', 'organiser_org').filter( + Q(organiser_ig_id=ig_id) | Q(scope_ig_id=ig_id), + status__in=PUBLISHED_STATUSES, + ) + + paginated = CommonUtils.get_paginated_queryset( + events, request, + search_fields=['title', 'venue_city'], + sort_fields={'start_datetime': 'start_datetime'}, + ) + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': _viewer_id(request), 'request': request}, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class ClusterEventListAPI(APIView): + """ + GET /events/ig/cluster// + All published events from IGs in a given cluster. + Cluster maps to the InterestGroup.category field + (values: coder | maker | manager | creative | others). + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Cluster Event List.", + responses={200: EventListItemSerializer}, + ) + def get(self, request, cluster): + ig_ids = list( + InterestGroup.objects.filter(category__iexact=cluster) + .values_list('id', flat=True) + ) + + if not ig_ids: + return CustomResponse().paginated_response( + data=[], + pagination={'count': 0, 'totalPages': 0, 'isNext': False, 'isPrev': False, 'nextPage': None}, + ) + + events = get_live_events().select_related('category', 'organiser_ig', 'organiser_org').filter( + organiser_ig_id__in=ig_ids, + status__in=PUBLISHED_STATUSES, + ) + + paginated = CommonUtils.get_paginated_queryset( + events, request, + search_fields=['title', 'venue_city'], + sort_fields={'start_datetime': 'start_datetime'}, + ) + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': _viewer_id(request), 'request': request}, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class CampusEventListAPI(APIView): + """ + GET /events/campus// + All published events scoped to or organised by a specific campus. + Supports ?cluster= to further filter by IG cluster. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Campus Event List.", + responses={200: EventListItemSerializer}, + ) + def get(self, request, campus_id): + org = Organization.objects.filter(id=campus_id.strip()).first() + if not org: + return CustomResponse(general_message='Campus not found.').get_failure_response() + + from django.db.models import Q + events = get_live_events().select_related('category', 'organiser_ig', 'organiser_org').filter( + Q(scope_org_id=campus_id) | Q(organiser_org_id=campus_id), + status__in=PUBLISHED_STATUSES, + ) + + # Optional cluster filter + if cluster := request.query_params.get('cluster'): + ig_ids = list( + InterestGroup.objects.filter(category__iexact=cluster) + .values_list('id', flat=True) + ) + events = events.filter(organiser_ig_id__in=ig_ids) + + paginated = CommonUtils.get_paginated_queryset( + events, request, + search_fields=['title', 'venue_city'], + sort_fields={'start_datetime': 'start_datetime'}, + ) + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': _viewer_id(request), 'request': request}, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class CampusIGEventListAPI(APIView): + """ + GET /events/campus-ig// + Events created by a specific campus IG chapter only. + campus_ig_id corresponds to the organiser_ci_id on events. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Campus I G Event List.", + responses={200: EventListItemSerializer}, + ) + def get(self, request, campus_ig_id): + events = get_live_events().select_related('category', 'organiser_ig', 'organiser_org').filter( + organiser_ci_id=campus_ig_id, + status__in=PUBLISHED_STATUSES, + ) + + paginated = CommonUtils.get_paginated_queryset( + events, request, + search_fields=['title', 'venue_city'], + sort_fields={'start_datetime': 'start_datetime'}, + ) + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': _viewer_id(request), 'request': request}, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class CompanyEventListAPI(APIView): + """ + GET /events/company// + All published events organised by a specific company/partner. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve Company Event List.", + responses={200: EventListItemSerializer}, + ) + def get(self, request, company_id): + org = Organization.objects.filter(id=company_id.strip()).first() + if not org: + return CustomResponse(general_message='Company not found.').get_failure_response() + + events = get_live_events().select_related('category', 'organiser_ig', 'organiser_org').filter( + organiser_org_id=company_id, + organiser_type=Event.OrganiserType.COMPANY, + status__in=PUBLISHED_STATUSES, + ) + + paginated = CommonUtils.get_paginated_queryset( + events, request, + search_fields=['title', 'venue_city'], + sort_fields={'start_datetime': 'start_datetime'}, + ) + serializer = EventListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': _viewer_id(request), 'request': request}, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) diff --git a/api/dashboard/events/serializers.py b/api/dashboard/events/serializers.py new file mode 100644 index 000000000..e2a126031 --- /dev/null +++ b/api/dashboard/events/serializers.py @@ -0,0 +1,739 @@ +""" +Shared serializers for the Events system. +All view modules import from this file. +""" +import uuid + +from django.utils.text import slugify +from rest_framework import serializers + +from db.events import Event, EventConnection, EventInterest, EventLog +from db.task import InterestGroup, TaskList +from db.organization import Organization +from db.user import User +from utils.utils import DateTimeUtils + +from .event_image_utils import resolve_event_image_url + + +# ───────────────────────────────────────────────────────────── +# MINIMAL / NESTED SHAPES +# ───────────────────────────────────────────────────────────── + +class MinimalUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'full_name', 'muid', 'profile_pic'] + + +class MinimalIGSerializer(serializers.ModelSerializer): + class Meta: + model = InterestGroup + fields = ['id', 'name', 'icon'] + + +class MinimalCampusSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = ['id', 'title', 'org_type'] + + +class MinimalCampusIGSerializer(serializers.Serializer): + """Represents a campus-IG chapter as (org_id, ig_id) pair.""" + org_id = serializers.CharField() + ig_id = serializers.CharField() + org_title = serializers.CharField() + ig_name = serializers.CharField() + + +# ───────────────────────────────────────────────────────────── +# ORGANISER INFO +# ───────────────────────────────────────────────────────────── + +class OrganizerInfoSerializer(serializers.Serializer): + """Reads organiser fields off the Event model directly.""" + organiser_type = serializers.CharField() + organiser_ig = serializers.SerializerMethodField() + organiser_campus = serializers.SerializerMethodField() + organiser_company = serializers.SerializerMethodField() + organiser_ci_id = serializers.CharField(allow_null=True, required=False) + + def get_organiser_ig(self, obj): + if obj.organiser_type in (Event.OrganiserType.GLOBAL_IG, Event.OrganiserType.CAMPUS_IG) and obj.organiser_ig: + return MinimalIGSerializer(obj.organiser_ig).data + return None + + def get_organiser_campus(self, obj): + if obj.organiser_type == Event.OrganiserType.CAMPUS and obj.organiser_org: + return MinimalCampusSerializer(obj.organiser_org).data + return None + + def get_organiser_company(self, obj): + if obj.organiser_type == Event.OrganiserType.COMPANY and obj.organiser_org: + return MinimalCampusSerializer(obj.organiser_org).data + return None + + +# ───────────────────────────────────────────────────────────── +# VENUE +# ───────────────────────────────────────────────────────────── + +class EventVenueSerializer(serializers.Serializer): + """Flattens venue_* fields from the Event model.""" + venue_type = serializers.CharField() + venue_address = serializers.CharField(allow_null=True, required=False) + venue_city = serializers.CharField(allow_null=True, required=False) + venue_maps_url = serializers.URLField(allow_null=True, required=False) + venue_online_link = serializers.CharField(allow_null=True, required=False) + venue_platform = serializers.CharField(allow_null=True, required=False) + + +# ───────────────────────────────────────────────────────────── +# COLLABORATORS +# ───────────────────────────────────────────────────────────── + +class EventCollaboratorSerializer(serializers.ModelSerializer): + entity_detail = serializers.SerializerMethodField() + + class Meta: + model = EventConnection + fields = [ + 'id', 'entity_type', 'entity_id', 'entity_detail', + 'role_label', 'invite_status', 'rejection_reason', + 'responded_at', 'created_at', + ] + + def get_entity_detail(self, obj): + try: + igs_map = self.context.get('igs_map') + orgs_map = self.context.get('orgs_map') + + if obj.entity_type == EventConnection.EntityType.COLLAB_IG: + if igs_map is not None: + ig = igs_map.get(str(obj.entity_id)) + else: + ig = InterestGroup.objects.filter(id=obj.entity_id).first() + return MinimalIGSerializer(ig).data if ig else None + elif obj.entity_type in ( + EventConnection.EntityType.COLLAB_CAMPUS, + EventConnection.EntityType.COLLAB_COMPANY, + ): + if orgs_map is not None: + org = orgs_map.get(str(obj.entity_id)) + else: + org = Organization.objects.filter(id=obj.entity_id).first() + return MinimalCampusSerializer(org).data if org else None + elif obj.entity_type == EventConnection.EntityType.COLLAB_CAMPUS_IG: + return {'campus_ig_id': obj.entity_id} + except Exception: + return None + return None + + +class MyEventInviteSerializer(EventCollaboratorSerializer): + event_id = serializers.CharField(source='event.id', read_only=True) + event_title = serializers.CharField(source='event.title', read_only=True) + event_start_datetime = serializers.DateTimeField(source='event.start_datetime', read_only=True) + event_cover_image = serializers.SerializerMethodField() + + class Meta(EventCollaboratorSerializer.Meta): + fields = EventCollaboratorSerializer.Meta.fields + [ + 'event_id', 'event_title', 'event_start_datetime', 'event_cover_image' + ] + + def get_event_cover_image(self, obj): + ev = getattr(obj, 'event', None) + if not ev: + return None + return resolve_event_image_url(ev.cover_image, self.context.get('request')) + + + +# ───────────────────────────────────────────────────────────── +# CO-OWNERS +# ───────────────────────────────────────────────────────────── + +class EventCoOwnerSerializer(serializers.ModelSerializer): + user = serializers.SerializerMethodField() + added_by = serializers.SerializerMethodField() + added_at = serializers.DateTimeField(source='created_at') + + class Meta: + model = EventConnection + fields = ['id', 'entity_id', 'user', 'added_by', 'added_at'] + + def get_user(self, obj): + users_map = self.context.get('users_map') + if users_map is not None: + user = users_map.get(str(obj.entity_id)) + else: + user = User.objects.filter(id=obj.entity_id).first() + return MinimalUserSerializer(user).data if user else None + + def get_added_by(self, obj): + return MinimalUserSerializer(obj.created_by).data + + +# ───────────────────────────────────────────────────────────── +# LINKED TASKS +# ───────────────────────────────────────────────────────────── + +class LinkedTaskSerializer(serializers.ModelSerializer): + ig = MinimalIGSerializer(read_only=True) + + class Meta: + model = TaskList + fields = [ + 'id', 'title', 'description', 'hashtag', + 'karma', 'bonus_time', 'bonus_karma', 'active', 'ig', + ] + + +# ───────────────────────────────────────────────────────────── +# EVENT TASK MANAGEMENT (full CRUD serializers) +# ───────────────────────────────────────────────────────────── + +class EventTaskSerializer(serializers.ModelSerializer): + """Read serializer for event-linked tasks (list + detail responses).""" + type = serializers.SerializerMethodField() + ig = MinimalIGSerializer(read_only=True) + level = serializers.SerializerMethodField() + channel = serializers.CharField(source='channel.name', default=None) + org = serializers.CharField(source='org.title', default=None) + created_by = MinimalUserSerializer(read_only=True) + + class Meta: + model = TaskList + fields = [ + 'id', 'hashtag', 'title', 'description', 'karma', + 'approval_status', 'active', 'type', 'ig', 'level', + 'channel', 'org', 'variable_karma', 'usage_count', + 'bonus_time', 'bonus_karma', + 'created_by', 'created_at', 'updated_at', + ] + + def get_type(self, obj): + if obj.type: + return {'id': str(obj.type.id), 'title': obj.type.title} + return None + + def get_level(self, obj): + if obj.level: + return {'id': str(obj.level.id), 'name': obj.level.name} + return None + + +class EventTaskWriteSerializer(serializers.ModelSerializer): + """Write serializer for creating/updating event-linked tasks.""" + + class Meta: + model = TaskList + fields = [ + 'hashtag', 'title', 'description', 'karma', + 'type', 'ig', 'level', 'org', 'bonus_time', + ] + extra_kwargs = { + 'hashtag': {'required': True}, + 'title': {'required': True}, + 'type': {'required': True}, + 'description': {'required': False, 'allow_blank': True}, + 'karma': {'required': False}, + 'ig': {'required': False, 'allow_null': True}, + 'level': {'required': False, 'allow_null': True}, + 'org': {'required': False, 'allow_null': True}, + 'bonus_time': {'required': False, 'allow_null': True}, + } + + +# ───────────────────────────────────────────────────────────── +# EVENT LIST ITEM (lean — for paginated feeds) +# ───────────────────────────────────────────────────────────── + +class EventListItemSerializer(serializers.ModelSerializer): + organizer = OrganizerInfoSerializer(source='*') + venue = EventVenueSerializer(source='*') + viewer_interest_status = serializers.SerializerMethodField() + cover_image = serializers.SerializerMethodField() + category_name = serializers.CharField(source='category.name', allow_null=True, read_only=True) + category_id = serializers.CharField(source='category.id', allow_null=True, read_only=True) + + class Meta: + model = Event + fields = [ + 'id', 'title', 'slug', 'cover_image', + 'status', 'scope', 'event_scope', 'event_type', 'start_datetime', 'end_datetime', + 'venue', 'organizer', 'is_featured', 'is_collaboration', + 'interest_count', 'min_karma', 'tags', 'user_limit', + 'category_id', 'category_name', 'viewer_interest_status', + ] + + def get_cover_image(self, obj): + return resolve_event_image_url(obj.cover_image, self.context.get('request')) + + def get_viewer_interest_status(self, obj): + user_id = self.context.get('user_id') + if not user_id: + return None + # Use queryset annotation when available (avoids N+1 per list item); + # fall back to a direct query if the annotation is not present (e.g. detail view). + if hasattr(obj, 'viewer_interested'): + return 'interested' if obj.viewer_interested else 'none' + exists = EventInterest.objects.filter(event=obj, user_id=user_id).exists() + return 'interested' if exists else 'none' + + +class EventCalendarItemSerializer(serializers.ModelSerializer): + start = serializers.DateTimeField(source='start_datetime', read_only=True) + end = serializers.DateTimeField(source='end_datetime', read_only=True) + category_name = serializers.CharField(source='category.name', allow_null=True, read_only=True) + organiser_name = serializers.SerializerMethodField() + + class Meta: + model = Event + fields = [ + 'id', 'title', 'slug', 'status', 'start', 'end', + 'venue_type', 'organiser_name', 'category_name', 'is_featured', + ] + + def get_organiser_name(self, obj): + if obj.organiser_type in [Event.OrganiserType.CAMPUS, Event.OrganiserType.COMPANY]: + return obj.organiser_org.title if obj.organiser_org else "muLearn" + elif obj.organiser_type in [Event.OrganiserType.GLOBAL_IG, Event.OrganiserType.CAMPUS_IG]: + return obj.organiser_ig.name if obj.organiser_ig else "muLearn" + return "muLearn" + + +# ───────────────────────────────────────────────────────────── +# EVENT DETAIL (full — for detail page) +# ───────────────────────────────────────────────────────────── + +class EventDetailSerializer(serializers.ModelSerializer): + organizer = OrganizerInfoSerializer(source='*') + venue = EventVenueSerializer(source='*') + collaborators = serializers.SerializerMethodField() + co_owners = serializers.SerializerMethodField() + linked_tasks = serializers.SerializerMethodField() + created_by = MinimalUserSerializer(read_only=True) + updated_by = MinimalUserSerializer(read_only=True) + viewer_interest_status = serializers.SerializerMethodField() + viewer_can_access_registration = serializers.SerializerMethodField() + viewer_access_blocked_reason = serializers.SerializerMethodField() + cover_image = serializers.SerializerMethodField() + banner_image = serializers.SerializerMethodField() + category_name = serializers.CharField(source='category.name', allow_null=True, read_only=True) + category_id = serializers.CharField(source='category.id', allow_null=True, read_only=True) + scope_org = MinimalCampusSerializer(read_only=True, allow_null=True) + scope_ig = MinimalIGSerializer(read_only=True, allow_null=True) + + class Meta: + model = Event + fields = [ + 'id', 'title', 'slug', 'description', + 'cover_image', 'banner_image', 'category_id', 'category_name', + 'status', 'scope', 'event_scope', 'event_type', 'scope_org', 'scope_ig', 'scope_ci_id', + 'organizer', 'venue', + 'start_datetime', 'end_datetime', + 'registration_url', 'registration_deadline', 'min_karma', + 'is_featured', 'is_collaboration', 'interest_count', + 'tags', 'user_limit', + 'linked_tasks', 'co_owners', 'collaborators', + 'viewer_interest_status', + 'viewer_can_access_registration', + 'viewer_access_blocked_reason', + 'created_by', 'updated_by', 'created_at', 'updated_at', + ] + + def get_cover_image(self, obj): + return resolve_event_image_url(obj.cover_image, self.context.get('request')) + + def get_banner_image(self, obj): + return resolve_event_image_url(obj.banner_image, self.context.get('request')) + + def get_collaborators(self, obj): + is_manage_view = self.context.get('is_manage_view', False) + qs = obj.connections.filter( + entity_type__in=EventConnection.COLLABORATOR_TYPES + ) + if not is_manage_view: + # Public view: only accepted collaborators + qs = qs.filter(invite_status=EventConnection.InviteStatus.ACCEPTED) + collabs = list(qs) + ig_ids = [c.entity_id for c in collabs if c.entity_type == EventConnection.EntityType.COLLAB_IG] + org_ids = [c.entity_id for c in collabs if c.entity_type in (EventConnection.EntityType.COLLAB_CAMPUS, EventConnection.EntityType.COLLAB_COMPANY)] + + igs = {str(ig.id): ig for ig in InterestGroup.objects.filter(id__in=ig_ids)} + orgs = {str(org.id): org for org in Organization.objects.filter(id__in=org_ids)} + + return EventCollaboratorSerializer( + collabs, many=True, + context={**self.context, 'igs_map': igs, 'orgs_map': orgs} + ).data + + def get_co_owners(self, obj): + qs = obj.connections.filter(entity_type=EventConnection.EntityType.CO_OWNER).select_related('created_by') + co_owners = list(qs) + user_ids = [c.entity_id for c in co_owners] + users = {str(u.id): u for u in User.objects.filter(id__in=user_ids)} + return EventCoOwnerSerializer( + co_owners, many=True, + context={**self.context, 'users_map': users} + ).data + + def get_linked_tasks(self, obj): + # Use event_fk_id (raw UUID) to avoid Django's FK type-check + # which rejects a new `Event` object where old `Events` is expected. + tasks = TaskList.objects.filter(event_fk_id=obj.id) + return LinkedTaskSerializer(tasks, many=True).data + + def _get_viewer_info(self, obj): + """Returns (user_id, viewer_karma). Cached per serializer call.""" + if hasattr(self, '_viewer_info_cache'): + return self._viewer_info_cache + user_id = self.context.get('user_id') + if not user_id: + self._viewer_info_cache = (None, None) + return self._viewer_info_cache + try: + from db.task import Wallet + wallet = Wallet.objects.filter(user_id=user_id).first() + karma = wallet.karma if wallet else 0 + except Exception: + karma = 0 + self._viewer_info_cache = (user_id, karma) + return self._viewer_info_cache + + def get_viewer_interest_status(self, obj): + user_id, _ = self._get_viewer_info(obj) + if not user_id: + return None + if hasattr(obj, 'viewer_interested'): + return 'interested' if obj.viewer_interested else 'none' + exists = EventInterest.objects.filter(event=obj, user_id=user_id).exists() + return 'interested' if exists else 'none' + + def get_viewer_can_access_registration(self, obj): + user_id, karma = self._get_viewer_info(obj) + if not user_id: + # Unauthenticated: only global events with no karma gate + return obj.scope == Event.Scope.GLOBAL and not obj.min_karma + if obj.status in (Event.Status.CANCELLED, Event.Status.COMPLETED, Event.Status.DRAFT): + return False + if obj.min_karma and karma < obj.min_karma: + return False + return True + + def get_viewer_access_blocked_reason(self, obj): + user_id, karma = self._get_viewer_info(obj) + if obj.status == Event.Status.CANCELLED: + return 'This event has been cancelled.' + if obj.status == Event.Status.COMPLETED: + return 'This event has already completed.' + if obj.status == Event.Status.DRAFT: + return 'This event is not yet published.' + if obj.min_karma and user_id and karma < obj.min_karma: + return f'Requires {obj.min_karma:,} karma. You have {karma:,}.' + return None + + +# ───────────────────────────────────────────────────────────── +# EVENT WRITE (create / update input) +# ───────────────────────────────────────────────────────────── + +class EventWriteSerializer(serializers.ModelSerializer): + """Input serializer for POST /manage/ and PUT/PATCH /manage//.""" + + class Meta: + model = Event + fields = [ + 'title', 'description', 'cover_image', 'banner_image', 'category', + 'start_datetime', 'end_datetime', + 'registration_url', 'registration_deadline', 'min_karma', + 'venue_type', 'venue_address', 'venue_city', + 'venue_maps_url', 'venue_online_link', 'venue_platform', + 'scope', 'scope_org', 'scope_ig', 'scope_ci_id', + 'organiser_type', 'organiser_ig', 'organiser_org', 'organiser_ci_id', + 'is_collaboration', 'is_featured', 'tags', 'user_limit', + 'event_scope', 'event_type', + ] + extra_kwargs = { + 'description': {'required': False, 'allow_null': True}, + 'cover_image': {'required': False, 'allow_null': True}, + 'banner_image': {'required': False, 'allow_null': True}, + 'category': {'required': False, 'allow_null': True}, + 'registration_url': {'required': False, 'allow_null': True}, + 'registration_deadline': {'required': False, 'allow_null': True}, + 'min_karma': {'required': False, 'allow_null': True}, + 'venue_address': {'required': False, 'allow_null': True}, + 'venue_city': {'required': False, 'allow_null': True}, + 'venue_maps_url': {'required': False, 'allow_null': True}, + 'venue_online_link': {'required': False, 'allow_null': True}, + 'venue_platform': {'required': False, 'allow_null': True}, + 'scope_org': {'required': False, 'allow_null': True}, + 'scope_ig': {'required': False, 'allow_null': True}, + 'scope_ci_id': {'required': False, 'allow_null': True}, + 'organiser_ig': {'required': False, 'allow_null': True}, + 'organiser_org': {'required': False, 'allow_null': True}, + 'organiser_ci_id': {'required': False, 'allow_null': True}, + 'is_collaboration': {'required': False}, + 'is_featured': {'required': False}, + 'tags': {'required': False, 'allow_null': True}, + 'user_limit': {'required': False}, + # event_scope is required on create; on PATCH it is optional + 'event_scope': {'required': False}, + # event_type is optional (defaults to 'others') + 'event_type': {'required': False}, + } + + def validate_venue_maps_url(self, value): + """Ensure venue_maps_url is a valid Google Maps URL when provided.""" + import re + from urllib.parse import urlparse + + if not value: + return value + + # Basic URL structure check + try: + parsed = urlparse(value) + if not parsed.scheme or not parsed.netloc: + raise serializers.ValidationError( + 'Enter a valid URL (e.g. https://maps.google.com/...).' + ) + except Exception: + raise serializers.ValidationError('Enter a valid URL.') + + # Only accept recognised Google Maps hostnames + # Allowed patterns: + # maps.google.* (e.g. maps.google.com, maps.google.co.in) + # www.google.*/maps (path must start with /maps) + # google.*/maps (path must start with /maps) + # goo.gl (Google shortlink) + host = parsed.netloc.lower().removeprefix('www.') + path = parsed.path.lower() + + google_maps_hosts = re.compile( + r'^maps\.google\.(?:com|co\.[a-z]{2})$' # maps.google.com, maps.google.co.in + r'|^google\.(?:com|co\.[a-z]{2})$' # google.com (needs /maps path) + r'|^goo\.gl$' # goo.gl shortlinks + ) + + if not google_maps_hosts.match(host): + raise serializers.ValidationError( + 'Only Google Maps URLs are accepted ' + '(e.g. https://maps.google.com/... or https://goo.gl/maps/...).' + ) + + # If host is google.* (not maps.google.*), path must start with /maps + if re.match(r'^google\.(?:com|co\.[a-z]{2})$', host) and not path.startswith('/maps'): + raise serializers.ValidationError( + 'Only Google Maps URLs are accepted ' + '(e.g. https://www.google.com/maps/...).' + ) + + return value + + def validate_category(self, value): + """Ensure the provided Category belongs to the event entity type.""" + if value is None: + return value + from db.task import Category as CategoryModel + # Re-fetch to confirm this category exists and is scoped to events + cat = CategoryModel.objects.filter( + id=value.id, + entity_type=CategoryModel.EntityType.EVENT, + ).first() + if not cat: + raise serializers.ValidationError( + 'Invalid category: must be an event category. ' + 'Use GET /events/meta/categories/ to list valid options.' + ) + return value + + def validate(self, attrs): + start = attrs.get('start_datetime') + end = attrs.get('end_datetime') + + # In a partial update (PATCH), if only one is provided, compare against the existing instance + if self.instance: + if not start: + start = self.instance.start_datetime + if not end: + end = self.instance.end_datetime + + if start and end: + # End date must not be before the start date + if end.date() < start.date(): + raise serializers.ValidationError( + {'end_datetime': 'End date cannot be before the start date.'} + ) + # For same-day events, end time must be strictly after start time + if end.date() == start.date() and end <= start: + raise serializers.ValidationError( + {'end_datetime': 'For same-day events, end time must be after start time.'} + ) + + # event_scope is required on creation (POST), optional on PATCH + if not self.instance and not attrs.get('event_scope'): + raise serializers.ValidationError( + {'event_scope': 'This field is required. Allowed values: maker, coder, manager, creative.'} + ) + + # Validate event_scope value against the allowed enum choices + event_scope = attrs.get('event_scope') + if event_scope and event_scope not in Event.EventScope.values: + raise serializers.ValidationError( + {'event_scope': f'Invalid value. Allowed: {", ".join(Event.EventScope.values)}.'} + ) + + # Enforce Campus Scope Ownership Validation + organiser_type = attrs.get('organiser_type', self.instance.organiser_type if self.instance else None) + scope = attrs.get('scope', self.instance.scope if self.instance else None) + + if organiser_type == Event.OrganiserType.CAMPUS and scope == Event.Scope.CAMPUS: + organiser_org = attrs.get('organiser_org', self.instance.organiser_org if self.instance else None) + scope_org = attrs.get('scope_org', self.instance.scope_org if self.instance else None) + + if scope_org != organiser_org: + raise serializers.ValidationError( + {'scope_org': "Campus scoped events can only target the organiser's own campus."} + ) + + return attrs + + def _generate_unique_slug(self, title): + base = slugify(title) + slug = base + counter = 1 + while Event.objects.filter(slug=slug).exists(): + slug = f'{base}-{counter}' + counter += 1 + return slug + + def create(self, validated_data): + user_id = self.context['user_id'] + validated_data['id'] = str(uuid.uuid4()) + validated_data['slug'] = self._generate_unique_slug(validated_data['title']) + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + return Event.objects.create(**validated_data) + + def update(self, instance, validated_data): + from .event_logger import build_diff, log_event_action + + user_id = self.context['user_id'] + + # Capture diff BEFORE applying changes so we have old values + changes = build_diff(instance, validated_data) + + if 'title' in validated_data and validated_data['title'] != instance.title: + validated_data['slug'] = self._generate_unique_slug(validated_data['title']) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.updated_by_id = user_id + instance.save() + + # Write structured audit log (only if something actually changed) + if changes: + log_event_action( + event=instance, + user_id=user_id, + action=EventLog.Action.UPDATED, + changes=changes, + ) + return instance + + +# ───────────────────────────────────────────────────────────── +# EDIT HISTORY (manage detail view) +# ───────────────────────────────────────────────────────────── + +class EventLogSerializer(serializers.ModelSerializer): + edited_by = MinimalUserSerializer(read_only=True) + action = serializers.SerializerMethodField() + summary = serializers.SerializerMethodField() + changes = serializers.SerializerMethodField() + details = serializers.SerializerMethodField() + + class Meta: + model = EventLog + fields = ['id', 'action', 'edited_by', 'summary', 'changes', 'details', 'edited_at'] + + # ── Helpers ────────────────────────────────────────────────────────────── + + def _cf(self, obj): + """Always return changed_fields as a dict (handles legacy list rows).""" + cf = obj.changed_fields + return cf if isinstance(cf, dict) else {} + + def get_action(self, obj): + return self._cf(obj).get('action', 'event_updated') + + def get_changes(self, obj): + return self._cf(obj).get('changes', {}) + + def get_details(self, obj): + return self._cf(obj).get('details', None) + + def get_summary(self, obj): + action = self.get_action(obj) + changes = self.get_changes(obj) + details = self.get_details(obj) or {} + + ACTION_SUMMARIES = { + 'event_created': lambda c, d: 'Event created', + 'event_updated': lambda c, d: f"Updated: {', '.join(c.keys())}" if c else 'Event updated', + 'event_published': lambda c, d: f"Event submitted for approval → {d.get('new_status', '')}".rstrip(' →'), + 'event_cancelled': lambda c, d: 'Event cancelled', + 'event_approved': lambda c, d: f"Event approved → {c.get('Status', {}).get('to', '')}".rstrip(' →'), + 'event_rejected': lambda c, d: f"Event rejected — {d.get('reason', 'no reason given')}", + 'event_featured': lambda c, d: 'Event marked as featured', + 'event_unfeatured': lambda c, d: 'Event removed from featured', + 'co_owner_added': lambda c, d: f"Co-owner added: {d.get('name', '')}".rstrip(': '), + 'co_owner_removed': lambda c, d: f"Co-owner removed: {d.get('name', '')}".rstrip(': '), + 'collaborator_invited': lambda c, d: f"{d.get('entity_type', 'Collaborator')} invited: {d.get('name', '')}".rstrip(': '), + 'collaborator_accepted': lambda c, d: f"{d.get('entity_type', 'Collaborator')} accepted the invite: {d.get('name', '')}".rstrip(': '), + 'collaborator_rejected': lambda c, d: f"{d.get('entity_type', 'Collaborator')} rejected the invite: {d.get('name', '')} — {d.get('reason', '')}".rstrip(' —: '), + 'collaborator_removed': lambda c, d: f"{d.get('entity_type', 'Collaborator')} removed: {d.get('name', '')}".rstrip(': '), + } + fn = ACTION_SUMMARIES.get(action) + return fn(changes, details) if fn else action.replace('_', ' ').title() + + +# ───────────────────────────────────────────────────────────── +# HELPERS (used inside views — not serializers) +# ───────────────────────────────────────────────────────────── + +def get_live_events(): + """Base queryset: non-deleted events.""" + return Event.objects.filter(deleted_at__isnull=True) + + +def can_manage_event(user_id, event): + """True if user is creator OR co-owner of this event, OR if it's a company event they belong to.""" + if event.created_by_id == user_id: + return True + + if event.organiser_type == Event.OrganiserType.COMPANY.value and event.organiser_org_id: + from db.organization import Organization + from db.company import Company + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import has_scope + + # Check if user is company creator for this org + org = Organization.objects.filter(id=event.organiser_org_id).first() + if org: + company = Company.objects.filter(name=org.title, company_user_id=user_id, status="verified").first() + if company: + return True + + # Check if user holds an active COMPANY_MENTOR grant for this org + if has_scope(user_id, MentorScopeGrant.ScopeType.COMPANY_MENTOR, event.organiser_org_id): + return True + + return EventConnection.objects.filter( + event=event, + entity_type=EventConnection.EntityType.CO_OWNER, + entity_id=user_id, + ).exists() diff --git a/api/dashboard/events/task_views.py b/api/dashboard/events/task_views.py new file mode 100644 index 000000000..aeee3829a --- /dev/null +++ b/api/dashboard/events/task_views.py @@ -0,0 +1,290 @@ +""" +Event Task Management API views. +CRUD operations for tasks linked to events. +Event organiser / co-owner / admin access required. +""" +import uuid + +from rest_framework.views import APIView + +from db.events import Event +from db.task import TaskList, TaskType, Channel, InterestGroup, Level +from db.organization import Organization +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.utils import CommonUtils, DateTimeUtils +from utils.types import RoleType + +from .serializers import ( + EventTaskSerializer, + EventTaskWriteSerializer, + can_manage_event, + get_live_events, +) +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s + + +def _check_event_task_permission(request, event_id): + """ + Validate that the event exists and the caller can manage it. + Returns (event, user_id, error_response) — error_response is None on success. + """ + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + + event = get_live_events().filter(id=event_id).first() + if not event: + return None, user_id, CustomResponse( + general_message='Event not found.' + ).get_failure_response() + + is_admin = RoleType.ADMIN.value in roles + if not is_admin and not can_manage_event(user_id, event): + return None, user_id, CustomResponse( + general_message='You do not have permission to manage tasks for this event.' + ).get_failure_response() + + return event, user_id, None + + +# ───────────────────────────────────────────────────────────────────────────── +# EVENT TASK LIST + CREATE +# ───────────────────────────────────────────────────────────────────────────── + +class EventTaskListCreateAPI(APIView): + """ + GET /events/manage//tasks/ → list tasks linked to this event + POST /events/manage//tasks/ → create a new task linked to this event + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="List all tasks linked to an event.", + responses={200: EventTaskSerializer(many=True)}, + ) + def get(self, request, event_id): + event, user_id, error = _check_event_task_permission(request, event_id) + if error: + return error + + tasks = TaskList.objects.select_related( + 'type', 'ig', 'level', 'channel', 'org', 'created_by' + ).filter(event_fk=event) + + paginated = CommonUtils.get_paginated_queryset( + tasks, + request, + search_fields=['title', 'hashtag', 'description'], + sort_fields={ + 'created_at': 'created_at', + 'karma': 'karma', + 'title': 'title', + }, + ) + + serializer = EventTaskSerializer( + paginated.get('queryset'), many=True + ) + + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated.get('pagination'), + ) + + @extend_schema( + tags=['Dashboard - Events'], + description="Create a new task linked to an event. Task will require admin approval.", + request=EventTaskWriteSerializer, + responses={200: EventTaskSerializer}, + ) + def post(self, request, event_id): + event, user_id, error = _check_event_task_permission(request, event_id) + if error: + return error + + serializer = EventTaskWriteSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + message=serializer.errors + ).get_failure_response() + + now = DateTimeUtils.get_current_utc_time() + task = serializer.save( + id=uuid.uuid4(), + event_fk=event, + approval_status='pending', + active=False, + variable_karma=False, + usage_count=1, + created_by_id=user_id, + updated_by_id=user_id, + created_at=now, + updated_at=now, + ) + + # Re-fetch with select_related for the response + task = TaskList.objects.select_related( + 'type', 'ig', 'level', 'channel', 'org', 'created_by' + ).get(pk=task.pk) + + return CustomResponse( + general_message='Task created and linked to event. Pending admin approval.', + response=EventTaskSerializer(task).data, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# EVENT TASK DETAIL (GET / PATCH / DELETE) +# ───────────────────────────────────────────────────────────────────────────── + +class EventTaskDetailAPI(APIView): + """ + GET /events/manage//tasks// → task detail + PATCH /events/manage//tasks// → partial update + DELETE /events/manage//tasks// → delete task + """ + authentication_classes = [CustomizePermission] + + def _get_task(self, event, task_id): + """Get a task that belongs to the given event.""" + return TaskList.objects.select_related( + 'type', 'ig', 'level', 'channel', 'org', 'created_by' + ).filter(id=task_id, event_fk=event).first() + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve a specific task linked to an event.", + responses={200: EventTaskSerializer}, + ) + def get(self, request, event_id, task_id): + event, user_id, error = _check_event_task_permission(request, event_id) + if error: + return error + + task = self._get_task(event, task_id) + if not task: + return CustomResponse( + general_message='Task not found or does not belong to this event.' + ).get_failure_response() + + return CustomResponse( + general_message='Task detail retrieved.', + response=EventTaskSerializer(task).data, + ).get_success_response() + + @extend_schema( + tags=['Dashboard - Events'], + description="Partially update a task linked to an event.", + request=EventTaskWriteSerializer, + responses={200: EventTaskSerializer}, + ) + def patch(self, request, event_id, task_id): + event, user_id, error = _check_event_task_permission(request, event_id) + if error: + return error + + task = self._get_task(event, task_id) + if not task: + return CustomResponse( + general_message='Task not found or does not belong to this event.' + ).get_failure_response() + + serializer = EventTaskWriteSerializer(task, data=request.data, partial=True) + if not serializer.is_valid(): + return CustomResponse( + message=serializer.errors + ).get_failure_response() + + serializer.save( + updated_by_id=user_id, + updated_at=DateTimeUtils.get_current_utc_time(), + ) + + # Re-fetch with select_related for the response + task = TaskList.objects.select_related( + 'type', 'ig', 'level', 'channel', 'org', 'created_by' + ).get(pk=task_id) + + return CustomResponse( + general_message='Task updated successfully.', + response=EventTaskSerializer(task).data, + ).get_success_response() + + @extend_schema( + tags=['Dashboard - Events'], + description="Delete a task linked to an event.", + responses={200: inline_serializer( + name='EventTaskDeleteResponse', + fields={'message': s.CharField()}, + )}, + ) + def delete(self, request, event_id, task_id): + event, user_id, error = _check_event_task_permission(request, event_id) + if error: + return error + + task = self._get_task(event, task_id) + if not task: + return CustomResponse( + general_message='Task not found or does not belong to this event.' + ).get_failure_response() + + task.delete() + + return CustomResponse( + general_message='Task deleted successfully.', + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# EVENT TASK METADATA (dropdown data) +# ───────────────────────────────────────────────────────────────────────────── + +class EventTaskMetaAPI(APIView): + """ + GET /events/manage//tasks/meta/ + Returns dropdown data for task creation/editing forms. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Events'], + description="Retrieve dropdown metadata for event task forms.", + responses={200: inline_serializer( + name='EventTaskMetaResponse', + fields={ + 'task_types': s.ListField(child=s.DictField()), + 'interest_groups': s.ListField(child=s.DictField()), + 'levels': s.ListField(child=s.DictField()), + 'channels': s.ListField(child=s.DictField()), + 'organizations': s.ListField(child=s.DictField()), + }, + )}, + ) + def get(self, request, event_id): + event, user_id, error = _check_event_task_permission(request, event_id) + if error: + return error + + return CustomResponse( + general_message='Task metadata retrieved.', + response={ + 'task_types': list( + TaskType.objects.values('id', 'title').order_by('title') + ), + 'interest_groups': list( + InterestGroup.objects.values('id', 'name').order_by('name') + ), + 'levels': list( + Level.objects.values('id', 'name').order_by('name') + ), + 'channels': list( + Channel.objects.values('id', 'name').order_by('name') + ), + 'organizations': list( + Organization.objects.values('id', 'title').order_by('title') + ), + }, + ).get_success_response() diff --git a/api/dashboard/events/tests/__init__.py b/api/dashboard/events/tests/__init__.py new file mode 100644 index 000000000..5c21d1e47 --- /dev/null +++ b/api/dashboard/events/tests/__init__.py @@ -0,0 +1 @@ +# init file diff --git a/api/dashboard/events/tests/test_manage_events.py b/api/dashboard/events/tests/test_manage_events.py new file mode 100644 index 000000000..db4558f91 --- /dev/null +++ b/api/dashboard/events/tests/test_manage_events.py @@ -0,0 +1,85 @@ +import pytest +from rest_framework.test import APIClient +from db.events import Event +from db.organization import Organization +from db.user import User, Role +from utils.types import RoleType +from unittest.mock import patch +from db.task import Category + +@pytest.fixture +def user_fixture(db): + return User.objects.create(id="u-event-owner", muid="MU-EVENT-OWNER", full_name="Event Owner User", email="owner2@test.com") + +@pytest.fixture +def auth_client(user_fixture): + client = APIClient() + client.force_authenticate(user=user_fixture) + return client + +@pytest.fixture +def category_fixture(db): + return Category.objects.create(id="cat-event", title="Event Cat", entity_type=Category.EntityType.EVENT) + +@pytest.fixture +def campus_fixture(db): + return Organization.objects.create(id="org-campus-1", title="Campus 1", org_type=Organization.OrganizationType.COLLEGE.value, code="CMP1") + +@pytest.fixture +def other_campus_fixture(db): + return Organization.objects.create(id="org-campus-2", title="Campus 2", org_type=Organization.OrganizationType.COLLEGE.value, code="CMP2") + +@pytest.mark.django_db +@patch('utils.permission.JWTUtils.fetch_role') +@patch('utils.permission.JWTUtils.fetch_user_id') +@patch('utils.permission.CustomizePermission.has_permission') +def test_campus_scope_validation_success(mock_has_perm, mock_fetch_user_id, mock_fetch_role, auth_client, user_fixture, campus_fixture, category_fixture): + mock_has_perm.return_value = True + mock_fetch_user_id.return_value = user_fixture.id + mock_fetch_role.return_value = [RoleType.CAMPUS_LEAD.value] + + payload = { + "title": "Campus Event", + "description": "Event for our campus", + "start_datetime": "2030-01-01T10:00:00Z", + "end_datetime": "2030-01-01T12:00:00Z", + "venue_type": Event.VenueType.ONLINE.value, + "organiser_type": Event.OrganiserType.CAMPUS.value, + "organiser_org": campus_fixture.id, + "scope": Event.Scope.CAMPUS.value, + "scope_org": campus_fixture.id, + } + + resp = auth_client.post("/api/v1/dashboard/events/manage/", data=payload, format="json") + + # Should be 200 OK since orgs match + assert resp.status_code == 200 + assert resp.json().get("statusCode") == 200 + +@pytest.mark.django_db +@patch('utils.permission.JWTUtils.fetch_role') +@patch('utils.permission.JWTUtils.fetch_user_id') +@patch('utils.permission.CustomizePermission.has_permission') +def test_campus_scope_validation_failure(mock_has_perm, mock_fetch_user_id, mock_fetch_role, auth_client, user_fixture, campus_fixture, other_campus_fixture, category_fixture): + mock_has_perm.return_value = True + mock_fetch_user_id.return_value = user_fixture.id + mock_fetch_role.return_value = [RoleType.CAMPUS_LEAD.value] + + payload = { + "title": "Cross Campus Event", + "description": "Trying to create for another campus", + "start_datetime": "2030-01-01T10:00:00Z", + "end_datetime": "2030-01-01T12:00:00Z", + "venue_type": Event.VenueType.ONLINE.value, + "organiser_type": Event.OrganiserType.CAMPUS.value, + "organiser_org": campus_fixture.id, + "scope": Event.Scope.CAMPUS.value, + "scope_org": other_campus_fixture.id, # Mismatch! + } + + resp = auth_client.post("/api/v1/dashboard/events/manage/", data=payload, format="json") + + # Should fail with 400 Bad Request since orgs mismatch + assert resp.status_code == 400 + assert "scope_org" in resp.json()["message"]["general"] + assert resp.json()["message"]["general"]["scope_org"][0] == "Campus scoped events can only target the organiser's own campus." diff --git a/api/dashboard/events/urls.py b/api/dashboard/events/urls.py index c9c36f2d1..7d32f8b17 100644 --- a/api/dashboard/events/urls.py +++ b/api/dashboard/events/urls.py @@ -1,9 +1,69 @@ from django.urls import path -from . import events_views +from . import public_views, manage_views, admin_views, scoped_views, meta_views, task_views, analytics_views urlpatterns = [ - path('', events_views.EventAPI.as_view()), - path('/', events_views.EventAPI.as_view()), - -] \ No newline at end of file + + # ── Meta (BEFORE wildcards) ─────────────────────────────── + path('meta/categories/', meta_views.EventCategoriesAPI.as_view()), + path('meta/organizer-options/', meta_views.OrganizerOptionsAPI.as_view()), + path('meta/collaboration-targets/', meta_views.CollaborationTargetsAPI.as_view()), + path('meta/event-type-scope/', meta_views.EventTypesScopesAPI.as_view()), + + # ── Scoped Feeds ────────────────────────────────────────── + # cluster must come BEFORE ig// to avoid being swallowed by wildcard + path('ig/cluster//', scoped_views.ClusterEventListAPI.as_view()), + path('ig//', scoped_views.IGEventListAPI.as_view()), + path('campus//', scoped_views.CampusEventListAPI.as_view()), + path('campus-ig//', scoped_views.CampusIGEventListAPI.as_view()), + path('company//', scoped_views.CompanyEventListAPI.as_view()), + + # ── Admin (BEFORE wildcard) ─────────────────── + path('admin/', admin_views.AdminEventListAPI.as_view()), + path('admin//approve/', admin_views.AdminEventApproveAPI.as_view()), + path('admin//reject/', admin_views.AdminEventRejectAPI.as_view()), + path('admin//feature/', admin_views.AdminEventFeatureAPI.as_view()), + + # ── Mentor & Campus Approvals ──────────────────────────── + path('mentor//approve/', manage_views.MentorEventApproveAPI.as_view()), + path('mentor//reject/', manage_views.MentorEventRejectAPI.as_view()), + path('campus//approve/', manage_views.CampusEventApproveAPI.as_view()), + path('campus//reject/', manage_views.CampusEventRejectAPI.as_view()), + + # ── Manage (BEFORE wildcard) ────────────────── + path('manage/', manage_views.ManageEventListCreateAPI.as_view()), + path('manage//publish/', manage_views.ManageEventPublishAPI.as_view()), + + # Co-owners + path('manage//co-owners/', manage_views.ManageEventCoOwnerAPI.as_view()), + path('manage//co-owners//', manage_views.ManageEventCoOwnerRemoveAPI.as_view()), + + # Collaborators — specific sub-actions BEFORE the remove wildcard + path('manage//collaborators/', manage_views.ManageEventCollaboratorAPI.as_view()), + path('manage//collaborators//accept/', manage_views.ManageEventCollaboratorAcceptAPI.as_view()), + path('manage//collaborators//reject/', manage_views.ManageEventCollaboratorRejectAPI.as_view()), + path('manage//collaborators//', manage_views.ManageEventCollaboratorRemoveAPI.as_view()), + + # Event Tasks + path('manage//tasks/meta/', task_views.EventTaskMetaAPI.as_view()), + path('manage//tasks//', task_views.EventTaskDetailAPI.as_view()), + path('manage//tasks/', task_views.EventTaskListCreateAPI.as_view()), + + # Event Analytics + path('manage//analytics/', analytics_views.EventAnalyticsAPI.as_view()), + + # Manage event detail (GET / PUT / PATCH / DELETE) + path('manage//', manage_views.ManageEventDetailAPI.as_view()), + + # ── User Dashboard ─────────────────────────────── + path('my-invites/', manage_views.MyEventInvitesAPI.as_view()), + + # ── Public (wildcards last) ─────────────────────────────── + path('calendar/', public_views.EventCalendarAPI.as_view()), + path('featured/', public_views.EventFeaturedAPI.as_view()), + path('is-featured/', public_views.EventFeaturedAPI.as_view()), + path('tasks/', public_views.EventTaskPublicListAPI.as_view()), + path('/interest/', public_views.EventInterestAPI.as_view()), + path('/', public_views.EventDetailAPI.as_view()), + path('', public_views.EventListAPI.as_view()), +] diff --git a/api/dashboard/home/__init__.py b/api/dashboard/home/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/api/dashboard/home/__init__.py @@ -0,0 +1 @@ + diff --git a/api/dashboard/home/urls.py b/api/dashboard/home/urls.py new file mode 100644 index 000000000..c1432e834 --- /dev/null +++ b/api/dashboard/home/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import LearnerDashboardSummaryAPIView, LearnerStreakAPIView + + +urlpatterns = [ + path("learner/summary/", LearnerDashboardSummaryAPIView.as_view(), name="learner-home-summary"), + path("learner/streak/", LearnerStreakAPIView.as_view(), name="learner-streak"), +] diff --git a/api/dashboard/home/views.py b/api/dashboard/home/views.py new file mode 100644 index 000000000..84f279669 --- /dev/null +++ b/api/dashboard/home/views.py @@ -0,0 +1,176 @@ +from datetime import timedelta + +from django.db.models import Sum +from django.utils import timezone +from rest_framework import status +from rest_framework.views import APIView + +from db.job import CompanyJob +from db.learning_circle import CircleMeetingLog, UserCircleLink +from db.task import KarmaActivityLog, Wallet +from db.user import User +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s + + +def _user_rank(user_id): + wallet = Wallet.objects.filter(user_id=user_id).first() + karma = wallet.karma if wallet else 0 + return Wallet.objects.filter(karma__gt=karma).count() + 1 + + +def _streak_payload(user): + activity_dates = list( + KarmaActivityLog.objects.filter(user=user) + .dates("created_at", "day", order="DESC")[:30] + ) + streak_count = 0 + if activity_dates: + expected = activity_dates[0] + for activity_date in activity_dates: + if activity_date == expected: + streak_count += 1 + expected = expected - timedelta(days=1) + else: + break + + return { + "streak_days": streak_count, + "last_activity_at": activity_dates[0].isoformat() if activity_dates else None, + "activity_dates": [d.isoformat() for d in activity_dates], + } + + +class LearnerDashboardSummaryAPIView(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Home'], description="Retrieve Learner Dashboard Summary.", + responses={200: inline_serializer( + name='HomeLearnerDashboardSummaryResponse', + fields={ + 'stats': inline_serializer( + name='HomeLearnerDashboardStats', + fields={ + 'weekly_karma': s.IntegerField(), + 'total_karma': s.IntegerField(), + 'rank': s.IntegerField(), + 'active_circles': s.IntegerField(), + 'streak_days': s.IntegerField(), + }, + ), + 'next_meeting': s.JSONField(allow_null=True), + 'quick_action_counts': inline_serializer( + name='HomeLearnerQuickActionCounts', + fields={ + 'circles': s.IntegerField(), + 'leaderboard_rank': s.IntegerField(), + 'job_openings': s.IntegerField(), + }, + ), + }, + )}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + user = User.objects.filter(id=user_id).select_related("wallet_user").first() + if not user: + return CustomResponse( + general_message="User not found", + message={"error_code": "USER_NOT_FOUND"}, + ).get_failure_response( + status_code=401, + http_status_code=status.HTTP_401_UNAUTHORIZED, + ) + + since = timezone.now() - timedelta(days=7) + weekly_karma = ( + KarmaActivityLog.objects.filter(user_id=user_id, created_at__gte=since) + .aggregate(total=Sum("karma")) + .get("total") + or 0 + ) + active_circles = UserCircleLink.objects.filter( + user_id=user_id, + accepted=True, + ).count() + rank = _user_rank(user_id) + streak = _streak_payload(user) + + next_meeting = ( + CircleMeetingLog.objects.filter( + circle_id__user_circle_link_circle__user_id=user_id, + circle_id__user_circle_link_circle__accepted=True, + meet_time__gte=timezone.now(), + ) + .select_related("circle_id") + .order_by("meet_time") + .first() + ) + + next_meeting_data = None + if next_meeting: + next_meeting_data = { + "id": str(next_meeting.id), + "circle_id": str(next_meeting.circle_id_id), + "circle_name": next_meeting.circle_id.title, + "title": next_meeting.title, + "starts_at": next_meeting.meet_time.isoformat(), + "duration_minutes": next_meeting.duration, + "meeting_link": next_meeting.meet_link, + "rsvp_status": "joined", + } + + return CustomResponse( + general_message="Learner dashboard summary fetched successfully", + response={ + "stats": { + "weekly_karma": weekly_karma, + "total_karma": getattr(getattr(user, "wallet_user", None), "karma", 0), + "rank": rank, + "active_circles": active_circles, + "streak_days": streak["streak_days"], + }, + "next_meeting": next_meeting_data, + "quick_action_counts": { + "circles": active_circles, + "leaderboard_rank": rank, + "job_openings": CompanyJob.objects.filter( + is_deleted=False, + status="Active", + ).count(), + }, + }, + ).get_success_response() + + +class LearnerStreakAPIView(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Home'], description="Retrieve Learner Streak.", + responses={200: inline_serializer( + name='HomeLearnerStreakResponse', + fields={ + 'streak_days': s.IntegerField(), + 'last_activity_at': s.CharField(allow_null=True), + 'activity_dates': s.ListField(child=s.CharField()), + }, + )}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + user = User.objects.filter(id=user_id).first() + if not user: + return CustomResponse( + general_message="User not found", + message={"error_code": "USER_NOT_FOUND"}, + ).get_failure_response( + status_code=401, + http_status_code=status.HTTP_401_UNAUTHORIZED, + ) + + return CustomResponse( + general_message="Learner streak fetched successfully", + response=_streak_payload(user), + ).get_success_response() diff --git a/api/dashboard/ig/dash_ig_serializer.py b/api/dashboard/ig/dash_ig_serializer.py index 95f986e9d..5406f57c3 100644 --- a/api/dashboard/ig/dash_ig_serializer.py +++ b/api/dashboard/ig/dash_ig_serializer.py @@ -1,6 +1,138 @@ from rest_framework import serializers +import json from db.task import InterestGroup +from db.user import User, Socials + + +def _resolve_muid_list(muid_list): + """ + Given a list like [{"muid": "foo@mulearn"}, ...], fetch each user's + details (including socials) from the DB and return an enriched list: + [ + { + "muid": "foo@mulearn", + "full_name": "Foo Bar", + "email": "foo@example.com", + "profile_pic": "https://...", # or null + "socials": { + "github": "...", + "linkedin": "...", + ... # null for unset fields + } + }, + ... + ] + MUIDs that don't match any User are included with null for extra fields. + """ + if not isinstance(muid_list, list): + return muid_list + + muids = [item.get("muid") for item in muid_list if isinstance(item, dict) and item.get("muid")] + + if not muids: + return muid_list + + # Batch-fetch users + user_objs = User.objects.filter(muid__in=muids) + user_map = {u.muid: u for u in user_objs} + + # Batch-fetch socials keyed by user_id + user_ids = [u.id for u in user_objs] + socials_qs = Socials.objects.filter(user_id__in=user_ids).values( + "user_id", "github", "facebook", "instagram", "linkedin", + "dribble", "behance", "stackoverflow", "medium", "hackerrank" + ) + socials_map = {s["user_id"]: s for s in socials_qs} + + enriched = [] + for item in muid_list: + if not isinstance(item, dict): + enriched.append(item) + continue + + muid = item.get("muid") + user = user_map.get(muid) + + if user: + raw_socials = socials_map.get(user.id) + socials = { + "github": raw_socials.get("github") if raw_socials else None, + "facebook": raw_socials.get("facebook") if raw_socials else None, + "instagram": raw_socials.get("instagram") if raw_socials else None, + "linkedin": raw_socials.get("linkedin") if raw_socials else None, + "dribble": raw_socials.get("dribble") if raw_socials else None, + "behance": raw_socials.get("behance") if raw_socials else None, + "stackoverflow": raw_socials.get("stackoverflow") if raw_socials else None, + "medium": raw_socials.get("medium") if raw_socials else None, + "hackerrank": raw_socials.get("hackerrank") if raw_socials else None, + } + + enriched.append({ + "muid": muid, + "full_name": user.full_name, + "email": user.email, + "profile_pic": user.profile_pic, + "socials": socials, + }) + else: + # muid not found — include as-is with null details + enriched.append({ + "muid": muid, + "full_name": None, + "email": None, + "profile_pic": None, + }) + + return enriched + + +def _resolve_ig_mentors(ig): + """ + Active IG mentors for this IG, read from UserIgLink (authoritative) + rather than the legacy InterestGroup.mentors JSON column, enriched with + the same shape _resolve_muid_list produces plus the mentor's company. + """ + from db.task import UserIgLink + from api.dashboard.mentor.dash_mentor_helper import get_mentor_company + from db.user import UserMentor + + links = UserIgLink.objects.filter( + ig=ig, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).select_related("user") + + user_ids = [link.user_id for link in links] + socials_qs = Socials.objects.filter(user_id__in=user_ids).values( + "user_id", "github", "facebook", "instagram", "linkedin", + "dribble", "behance", "stackoverflow", "medium", "hackerrank" + ) + socials_map = {s["user_id"]: s for s in socials_qs} + mentor_profiles = { + m.user_id: m + for m in UserMentor.objects.filter(user_id__in=user_ids).select_related("org") + } + + mentors = [] + for link in links: + user = link.user + raw_socials = socials_map.get(user.id) + socials = { + key: (raw_socials.get(key) if raw_socials else None) + for key in ["github", "facebook", "instagram", "linkedin", + "dribble", "behance", "stackoverflow", "medium", "hackerrank"] + } + mentor_profile = mentor_profiles.get(user.id) + mentors.append({ + "muid": user.muid, + "full_name": user.full_name, + "email": user.email, + "profile_pic": user.profile_pic, + "company": get_mentor_company(mentor_profile) if mentor_profile else None, + "socials": socials, + }) + return mentors class InterestGroupSerializer(serializers.ModelSerializer): @@ -11,15 +143,29 @@ class InterestGroupSerializer(serializers.ModelSerializer): category = serializers.ChoiceField( choices=["maker", "coder", "creative", "manager", "others"] ) + status = serializers.ChoiceField( + choices=["active", "requested", "cancelled", "rejected"] + ) class Meta: model = InterestGroup fields = [ "id", "name", + "resource", + "about", + "prerequisites", + "career_opportunities", + "top_blogs", + "people_to_follow", + "leads", + "mentors", + "thinktank", + "office_hours", "icon", "code", "category", + "status", "members", "updated_by", "updated_at", @@ -30,9 +176,118 @@ class Meta: def get_members(self, obj): return obj.user_ig_link_ig.all().count() + def to_representation(self, instance): + """Convert JSON-serialized text fields back to Python objects for API output. + For 'leads' and 'mentors', further enrich with user details from DB.""" + data = super().to_representation(instance) + + # Plain JSON fields — just parse the string back to Python + plain_json_fields = [ + "prerequisites", + "career_opportunities", + "top_blogs", + "people_to_follow", + ] + + for field in plain_json_fields: + val = data.get(field) + if isinstance(val, str) and val: + try: + data[field] = json.loads(val) + except Exception: + pass # leave as-is (plain string) + + # MUID fields — parse + enrich with user details + for field in ["leads"]: + val = data.get(field) + if isinstance(val, str) and val: + try: + parsed = json.loads(val) + data[field] = _resolve_muid_list(parsed) + except Exception: + pass # leave as-is if parsing fails + + # 'mentors' is served from UserIgLink (the authoritative IG-permission + # table), not the legacy InterestGroup.mentors JSON column, so the IG + # detail page always reflects actual mentor authority. + data["mentors"] = _resolve_ig_mentors(instance) + + return data + class InterestGroupCreateUpdateSerializer(serializers.ModelSerializer): class Meta: model = InterestGroup - fields = ["name", "code", "category", "icon", "created_by", "updated_by"] + fields = [ + "name", + "code", + "category", + "status", + "icon", + "about", + "prerequisites", + "career_opportunities", + "resource", + "top_blogs", + "people_to_follow", + "leads", + "mentors", + "thinktank", + "office_hours", + "created_by", + "updated_by", + ] + + +class InterestGroupRequestSerializer(serializers.ModelSerializer): + """Serializer for user-submitted IG creation requests.""" + + class Meta: + model = InterestGroup + fields = [ + "name", + "code", + "category", + "icon", + "about", + "prerequisites", + "career_opportunities", + "resource", + "top_blogs", + "people_to_follow", + "leads", + "mentors", + "thinktank", + "office_hours", + ] + extra_kwargs = { + "name": {"required": True}, + "code": {"required": True}, + "category": {"required": True}, + "icon": {"required": True}, + } + +class InterestGroupRequestGetSerializer(InterestGroupSerializer): + requester_muid = serializers.CharField(source="created_by.muid", read_only=True) + requester_name = serializers.CharField(source="created_by.full_name", read_only=True) + company_name = serializers.SerializerMethodField() + + class Meta(InterestGroupSerializer.Meta): + fields = InterestGroupSerializer.Meta.fields + [ + "requester_muid", + "requester_name", + "company_name", + ] + + def get_company_name(self, obj): + from utils.types import RoleType, OrganizationType + if obj.created_by: + roles = obj.created_by.user_role_link_user.all().values_list("role__title", flat=True) + if RoleType.COMPANY.value in roles: + company_link = obj.created_by.user_organization_link_user.filter( + org__org_type=OrganizationType.COMPANY.value + ).first() + if company_link: + return company_link.org.title + return None diff --git a/api/dashboard/ig/dash_ig_view.py b/api/dashboard/ig/dash_ig_view.py index 41c30752e..bfa4f256b 100644 --- a/api/dashboard/ig/dash_ig_view.py +++ b/api/dashboard/ig/dash_ig_view.py @@ -1,7 +1,8 @@ from django.db.models import Count from rest_framework.views import APIView - +from django.db import transaction from db.task import InterestGroup +from db.user import User, Role from utils.permission import CustomizePermission from utils.permission import JWTUtils, role_required from utils.response import CustomResponse @@ -10,16 +11,66 @@ from .dash_ig_serializer import ( InterestGroupSerializer, InterestGroupCreateUpdateSerializer, + InterestGroupRequestSerializer, + InterestGroupRequestGetSerializer, ) +import json +import uuid from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page from api.dashboard.roles.dash_roles_serializer import RoleDashboardSerializer -from db.user import Role +from db.task import UserIgLink,UserIgLvlLink, Level +from db.user import Role, UserMentor, UserRoleLink +from utils.types import RoleType +from utils.utils import DateTimeUtils +from drf_spectacular.utils import extend_schema +from api.notification.notifications_utils import NotificationUtils + + +def _validate_muids(request_data, fields=("leads", "mentors")): + """ + Validate that every muid in the given fields actually exists in the User table. + Returns (is_valid, error_message). + """ + for fld in fields: + raw = request_data.get(fld) + if not raw: + continue + # raw may already be a list (before json.dumps) or a string (already dumped) + if isinstance(raw, str): + try: + raw = json.loads(raw) + except Exception: + continue + if not isinstance(raw, list): + continue + + muids = [ + item.get("muid") + for item in raw + if isinstance(item, dict) and item.get("muid") + ] + if not muids: + continue + + existing = set( + User.objects.filter(muid__in=muids).values_list("muid", flat=True) + ) + invalid = [m for m in muids if m not in existing] + if invalid: + return False, f"Invalid MUIDs in '{fld}': {invalid}" + + return True, None class InterestGroupAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Ig'], + description="Retrieve Interest Group.", + responses={200: InterestGroupSerializer}, + ) def get(self, request): ig_queryset = ( InterestGroup.objects.select_related("created_by", "updated_by") @@ -38,6 +89,7 @@ def get(self, request): { "name": "name", "members": "members", + "status": "status", "updated_on": "updated_at", "updated_by": "updated_by__full_name", "created_on": "created_at", @@ -54,11 +106,37 @@ def get(self, request): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description="Create Interest Group.", + request=InterestGroupCreateUpdateSerializer, + responses={200: RoleDashboardSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) request_data = request.data + # Validate MUIDs for leads/mentors before serializing + is_valid, error_msg = _validate_muids(request_data) + if not is_valid: + return CustomResponse(general_message=error_msg).get_failure_response() + + # serialize JSON-able fields to strings for DB storage + for fld in [ + "prerequisites", + "career_opportunities", + "top_blogs", + "people_to_follow", + "leads", + "mentors", + ]: + if fld in request_data and not isinstance(request_data.get(fld), str): + try: + request_data[fld] = json.dumps(request_data.get(fld)) + except Exception: + pass + request_data["created_by"] = request_data["updated_by"] = user_id serializer = InterestGroupCreateUpdateSerializer( @@ -66,59 +144,37 @@ def post(self, request): ) if serializer.is_valid(): - serializer.save() - - role_serializer = RoleDashboardSerializer( - data={ - "title": request_data.get("name"), - "description": request_data.get("name") + " Interest Group Member", - "created_by": request_data.get("created_by"), - "updated_by": request_data.get("updated_by"), - }, - context={"request": request}, - ) - - if role_serializer.is_valid(): - role_serializer.save() - else: - return CustomResponse( - general_message=role_serializer.errors - ).get_failure_response() - - campus_role_serializer = RoleDashboardSerializer( - data={ - "title": RoleType.IG_CAMPUS_LEAD_ROLE(request_data.get("code")), - "description": request_data.get("name") - + " Intrest Group Campus Lead", - "created_by": request_data.get("created_by"), - "updated_by": request_data.get("updated_by"), - }, - context={"request": request}, - ) - - if campus_role_serializer.is_valid(): - campus_role_serializer.save() - else: - return CustomResponse( - general_message=campus_role_serializer.errors - ).get_failure_response() - - ig_lead_role_serializer = RoleDashboardSerializer( - data={ - "title": RoleType.IG_LEAD_ROLE(request_data.get("code")), - "description": request_data.get("name") + " Interest Group Lead", - "created_by": request_data.get("created_by"), - "updated_by": request_data.get("updated_by"), - }, - context={"request": request}, - ) - - if ig_lead_role_serializer.is_valid(): - ig_lead_role_serializer.save() - else: - return CustomResponse( - general_message=ig_lead_role_serializer.errors - ).get_failure_response() + with transaction.atomic(): + serializer.save() + + ig_name = request_data.get("name") + ig_code = request_data.get("code") + + roles_to_ensure = [ + { + "title": ig_name, + "description": f"{ig_name} Interest Group Member", + }, + { + "title": RoleType.IG_CAMPUS_LEAD_ROLE(ig_code), + "description": f"{ig_name} Interest Group Campus Lead", + }, + { + "title": RoleType.IG_LEAD_ROLE(ig_code), + "description": f"{ig_name} Interest Group Lead", + }, + ] + + for role_data in roles_to_ensure: + Role.objects.get_or_create( + title=role_data["title"], + defaults={ + "id": str(uuid.uuid4()), + "description": role_data["description"], + "created_by_id": request_data.get("created_by"), + "updated_by_id": request_data.get("updated_by"), + } + ) DiscordWebhooks.general_updates( WebHookCategory.INTEREST_GROUP.value, @@ -134,6 +190,12 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description="Update Interest Group.", + request=InterestGroupCreateUpdateSerializer, + responses={200: InterestGroupSerializer}, + ) def put(self, request, pk): user_id = JWTUtils.fetch_user_id(request) ig = InterestGroup.objects.get(id=pk) @@ -142,6 +204,25 @@ def put(self, request, pk): ig_old_code = ig.code request_data = request.data + + # Validate MUIDs for leads/mentors before serializing + is_valid, error_msg = _validate_muids(request_data) + if not is_valid: + return CustomResponse(general_message=error_msg).get_failure_response() + + for fld in [ + "prerequisites", + "career_opportunities", + "top_blogs", + "people_to_follow", + "leads", + "mentors", + ]: + if fld in request_data and not isinstance(request_data.get(fld), str): + try: + request_data[fld] = json.dumps(request_data.get(fld)) + except Exception: + pass request_data["updated_by"] = user_id serializer = InterestGroupCreateUpdateSerializer( @@ -195,6 +276,9 @@ def put(self, request, pk): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Ig'], description="Delete Interest Group.", + responses={200: InterestGroupSerializer}, + ) def delete(self, request, pk): ig = InterestGroup.objects.filter(id=pk).first() @@ -228,6 +312,11 @@ class InterestGroupCSV(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description="Retrieve Interest Group C S V.", + responses={200: InterestGroupSerializer}, + ) def get(self, request): ig_serializer = ( InterestGroup.objects.select_related("created_by", "updated_by") @@ -243,8 +332,12 @@ def get(self, request): class InterestGroupGetAPI(APIView): authentication_classes = [CustomizePermission] - @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description="Retrieve Interest Group Get.", + responses={200: InterestGroupSerializer}, + ) def get(self, request, pk): ig_data = InterestGroup.objects.filter(id=pk).first() @@ -259,9 +352,417 @@ def get(self, request, pk): response={"interestGroup": serializer.data} ).get_success_response() + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description="Partially update Interest Group Get.", + request=InterestGroupCreateUpdateSerializer, + responses={200: InterestGroupSerializer}, + ) + def patch(self, request, pk): + """Allow IG Lead or Admin to update IG editable fields.""" + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + ig = InterestGroup.objects.filter(id=pk).first() + if not ig: + return CustomResponse(general_message="Interest Group Does Not Exist").get_failure_response() + + # Permission: Admins or IG Lead role for this IG code + ig_lead_role_title = RoleType.IG_LEAD_ROLE(ig.code) + if (RoleType.ADMIN.value not in roles) and (ig_lead_role_title not in roles): + return CustomResponse(general_message="You do not have permission to update this Interest Group").get_failure_response() + + request_data = request.data + + # Validate MUIDs for leads/mentors before serializing + is_valid, error_msg = _validate_muids(request_data) + if not is_valid: + return CustomResponse(general_message=error_msg).get_failure_response() + + for fld in [ + "prerequisites", + "career_opportunities", + "top_blogs", + "people_to_follow", + "leads", + "mentors", + ]: + if fld in request_data and not isinstance(request_data.get(fld), str): + try: + request_data[fld] = json.dumps(request_data.get(fld)) + except Exception: + pass + request_data["updated_by"] = user_id + + serializer = InterestGroupCreateUpdateSerializer(data=request_data, instance=ig, partial=True) + + if serializer.is_valid(): + serializer.save() + + # ── Side-effect: assign Mentor role + UserIgLink for new mentors ──── + raw_mentors = request.data.get("mentors") + if raw_mentors: + if isinstance(raw_mentors, str): + try: + raw_mentors = json.loads(raw_mentors) + except Exception: + raw_mentors = [] + + mentor_role = Role.objects.filter(title=RoleType.MENTOR.value).first() + for item in (raw_mentors or []): + muid = item.get("muid") if isinstance(item, dict) else item + if not muid: + continue + target_user = User.objects.filter(muid=muid).first() + if not target_user: + continue + + # 1. Ensure an approved UserMentor profile exists. + # Never overwrite mentor_tier on an existing profile — + # a Company/Campus mentor gaining IG-mentor authority is + # additive (via the MentorScopeGrant below), not a tier + # replacement. + now = DateTimeUtils.get_current_utc_time() + mentor_profile, created = UserMentor.objects.get_or_create( + user=target_user, + defaults={ + "status": UserMentor.Status.APPROVED, + "mentor_tier": UserMentor.MentorTier.IG_MENTOR, + "verified_by_id": user_id, + "verified_at": now, + "created_by_id": user_id, + "updated_by_id": user_id, + "created_at": now, + "updated_at": now, + }, + ) + if not created and mentor_profile.status != UserMentor.Status.APPROVED: + mentor_profile.status = UserMentor.Status.APPROVED + mentor_profile.verified_by_id = user_id + mentor_profile.verified_at = now + mentor_profile.updated_by_id = user_id + mentor_profile.updated_at = now + mentor_profile.save(update_fields=[ + "status", "verified_by_id", "verified_at", + "updated_by_id", "updated_at", + ]) + + # 1b. Grant IG-scoped authority additively. + from db.user import MentorScopeGrant + grant, grant_created = MentorScopeGrant.objects.get_or_create( + mentor=mentor_profile, + scope_type=MentorScopeGrant.ScopeType.IG_MENTOR, + scope_id=str(ig.id), + defaults={ + "is_active": True, + "granted_by_id": user_id, + "granted_at": now, + }, + ) + if not grant_created and not grant.is_active: + grant.is_active = True + grant.revoked_by = None + grant.revoked_at = None + grant.save(update_fields=["is_active", "revoked_by", "revoked_at"]) + + # 2. Assign Mentor role (idempotent) + if mentor_role: + UserRoleLink.objects.get_or_create( + user=target_user, + role=mentor_role, + defaults={"verified": True, "created_by_id": user_id}, + ) + + # 3. Create active UserIgLink (idempotent) + link, link_created = UserIgLink.objects.get_or_create( + user=target_user, + ig=ig, + assignment_type=UserIgLink.AssignmentType.MENTOR, + defaults={ + "is_active": True, + "assigned_by_id": user_id, + "created_by_id": user_id, + }, + ) + if not link_created and not link.is_active: + link.is_active = True + link.assigned_by_id = user_id + link.save() + + # 4. Notify the newly assigned mentor + if created or grant_created or link_created: + caller = User.objects.filter(id=user_id).first() + caller_name = caller.full_name if caller else "An IG Lead" + NotificationUtils.insert_notification( + user=target_user, + title=f"IG Mentor: {ig.name}"[:50], + description=( + f"{caller_name} has assigned you as an IG Mentor for " + f"{ig.name}. You can now create sessions and manage " + f"tasks within this interest group." + )[:200], + button='View', + url=f'/interest-groups/{ig.id}/', + created_by=caller, + ) + + return CustomResponse(response={"interestGroup": serializer.data}).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + +class InterestGroupRequestAPI(APIView): + """API endpoint for users to submit and retrieve IG creation requests.""" + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.COMPANY.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description="Retrieve Interest Group Request.", + responses={200: InterestGroupRequestGetSerializer}, + ) + def get(self, request): + """Retrieve Interest Group requests created by a company user. + + Query Parameters: + - user_id (optional): Filter by specific company user ID. + If not provided, defaults to the authenticated user's ID. + Only admins can query other users' requests. + - status (optional): Filter by IG status (requested, active, rejected, cancelled) + """ + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + target_user_id = request.query_params.get('user_id') + status_filter = request.query_params.get('status') + is_admin = RoleType.ADMIN.value in roles + + ig_queryset = InterestGroup.objects.select_related( + "created_by", "updated_by" + ).prefetch_related( + "user_ig_link_ig", + "created_by__user_role_link_user__role", + "created_by__user_organization_link_user__org" + ) + + if target_user_id: + if target_user_id != user_id and not is_admin: + return CustomResponse( + general_message="You can only view your own IG requests" + ).get_failure_response() + ig_queryset = ig_queryset.filter(created_by_id=target_user_id) + else: + if not is_admin: + ig_queryset = ig_queryset.filter(created_by_id=user_id) + + if status_filter: + valid_statuses = ['active', 'requested', 'cancelled', 'rejected'] + if status_filter not in valid_statuses: + return CustomResponse( + general_message=f"Invalid status. Must be one of: {', '.join(valid_statuses)}" + ).get_failure_response() + ig_queryset = ig_queryset.filter(status=status_filter) + + paginated_queryset = CommonUtils.get_paginated_queryset( + ig_queryset, + request, + ["name", "code", "category"], + { + "name": "name", + "status": "status", + "ig_name": "name", + "user_full_name": "created_by__full_name", + "created_at": "created_at", + "created_on": "created_at", + "updated_on": "updated_at", + }, + ) + + ig_serializer_data = InterestGroupRequestGetSerializer( + paginated_queryset.get("queryset"), many=True + ).data + + return CustomResponse().paginated_response( + data=ig_serializer_data, + pagination=paginated_queryset.get("pagination") + ) + + @role_required([RoleType.ADMIN.value, RoleType.COMPANY.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description="Create Interest Group Request.", + request=InterestGroupRequestSerializer, + responses={200: InterestGroupSerializer}, + ) + def post(self, request): + """Submit a new Interest Group creation request.""" + user_id = JWTUtils.fetch_user_id(request) + + request_data = request.data.copy() + + # Validate MUIDs for leads/mentors before serializing + is_valid, error_msg = _validate_muids(request_data) + if not is_valid: + return CustomResponse(general_message=error_msg).get_failure_response() + + for fld in [ + "prerequisites", + "career_opportunities", + "top_blogs", + "people_to_follow", + "leads", + "mentors", + ]: + if fld in request_data and not isinstance(request_data.get(fld), str): + try: + request_data[fld] = json.dumps(request_data.get(fld)) + except Exception: + pass + + serializer = InterestGroupRequestSerializer(data=request_data) + + if serializer.is_valid(): + ig_instance = serializer.save( + created_by_id=user_id, + updated_by_id=user_id, + status="requested" + ) + response_serializer = InterestGroupSerializer(ig_instance) + + return CustomResponse( + response={"interestGroup": response_serializer.data}, + general_message="Interest Group request submitted successfully. It will be reviewed by admins." + ).get_success_response() + + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description="Partially update Interest Group Request.", + responses={200: InterestGroupSerializer}, + ) + def patch(self, request, pk): + """Update Interest Group request status (Admin only). + + Allowed status transitions: + - requested → active + - requested → rejected + - requested → cancelled + - any status → any status (admin override) + """ + user_id = JWTUtils.fetch_user_id(request) + + try: + ig = InterestGroup.objects.get(id=pk) + except InterestGroup.DoesNotExist: + return CustomResponse( + general_message="Interest Group not found" + ).get_failure_response() + + new_status = request.data.get('status') + + valid_statuses = ['active', 'requested', 'cancelled', 'rejected'] + if not new_status: + return CustomResponse( + general_message="Status field is required" + ).get_failure_response() + + if new_status not in valid_statuses: + return CustomResponse( + general_message=f"Invalid status. Must be one of: {', '.join(valid_statuses)}" + ).get_failure_response() + + ig.status = new_status + ig.updated_by_id = user_id + ig.save() + + # Notify the original requester + actor = User.objects.filter(id=user_id).first() + requester = ig.created_by + if actor and requester and str(requester.id) != user_id: + if new_status == 'active': + NotificationUtils.insert_notification( + user=requester, + title='Interest Group Approved', + description=f'Your Interest Group request "{ig.name}" has been approved and is now active!', + button='View', + url=f'/interest-groups/{ig.id}/', + created_by=actor, + ) + elif new_status == 'rejected': + NotificationUtils.insert_notification( + user=requester, + title='Interest Group Rejected', + description=f'Your Interest Group request "{ig.name}" has been rejected.', + button=None, + url=None, + created_by=actor, + ) + + response_serializer = InterestGroupSerializer(ig) + + return CustomResponse( + response={"interestGroup": response_serializer.data}, + general_message=f"Interest Group status updated to '{new_status}'" + ).get_success_response() + + @role_required([RoleType.ADMIN.value, RoleType.COMPANY.value]) + @extend_schema( + tags=['Dashboard - Ig'], + description=( + "Cancel an IG creation request. " + "Company users can only cancel their own requests that are still in 'requested' status. " + "Admins can cancel any request regardless of status or owner." + ), + responses={200: InterestGroupSerializer}, + ) + def delete(self, request, pk): + """Cancel an IG creation request. + + - Company users: can cancel only their own request, only when status='requested'. + - Admins: can cancel any request at any status. + """ + user_id = JWTUtils.fetch_user_id(request) + roles = JWTUtils.fetch_role(request) + is_admin = RoleType.ADMIN.value in roles + + try: + ig = InterestGroup.objects.get(id=pk) + except InterestGroup.DoesNotExist: + return CustomResponse( + general_message="Interest Group request not found." + ).get_failure_response() + + # Ownership check for non-admins + if not is_admin and str(ig.created_by_id) != str(user_id): + return CustomResponse( + general_message="You can only cancel your own IG requests." + ).get_failure_response() + + # Status check for non-admins — only 'requested' can be cancelled by the requester + if not is_admin and ig.status != 'requested': + return CustomResponse( + general_message=f"Cannot cancel a request that is already '{ig.status}'. Only 'requested' status can be cancelled." + ).get_failure_response() + + ig.status = 'cancelled' + ig.updated_by_id = user_id + ig.save() + + return CustomResponse( + general_message="Interest Group request cancelled successfully." + ).get_success_response() + class InterestGroupListApi(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema( + tags=['Dashboard - Ig'], + description="Retrieve Interest Group List Api.", + responses={200: InterestGroupSerializer}, + ) def get(self, request): ig = ( InterestGroup.objects.all() @@ -275,3 +776,94 @@ def get(self, request): return CustomResponse( response={"interestGroup": serializer.data} ).get_success_response() + +class InterestGroupMembershipAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Ig'], + description="Join Interest Group instantly.", + responses={200: InterestGroupSerializer}, + ) + def post(self, request, pk): + user_id = JWTUtils.fetch_user_id(request) + + ig = InterestGroup.objects.filter(id=pk, status='active').first() + if not ig: + return CustomResponse(general_message="Interest Group not found or inactive").get_failure_response() + + with transaction.atomic(): + existing_link = UserIgLink.objects.filter( + user_id=user_id, + ig_id=pk, + assignment_type=UserIgLink.AssignmentType.LEARNER + ).first() + + if existing_link and existing_link.is_active: + return CustomResponse(general_message="Already joined this Interest Group").get_success_response() + + active_learner_ig_count = UserIgLink.objects.filter( + user_id=user_id, + assignment_type=UserIgLink.AssignmentType.LEARNER, + is_active=True + ).count() + + # enforce max 3 selected learner IGs + if not existing_link and active_learner_ig_count >= 3: + return CustomResponse(general_message="Cannot join more than 3 interest groups").get_failure_response() + + if existing_link: + existing_link.is_active = True + existing_link.unassigned_at = None + existing_link.assigned_by_id = user_id + existing_link.save(update_fields=["is_active", "unassigned_at", "assigned_by"]) + else: + UserIgLink.objects.create( + id=uuid.uuid4(), + user_id=user_id, + ig_id=pk, + assignment_type=UserIgLink.AssignmentType.LEARNER, + is_active=True, + assigned_by_id=user_id, + created_by_id=user_id, + ) + + # ensure level-1 IG link exists + level_1 = Level.objects.filter(level_order=1).first() + if level_1: + UserIgLvlLink.objects.get_or_create( + user_id=user_id, + ig_id=pk, + defaults={ + "id": uuid.uuid4(), + "level_id": level_1.id, + "created_by_id": user_id, + "updated_by_id": user_id, + }, + ) + + return CustomResponse(general_message="Joined Interest Group successfully").get_success_response() + + @extend_schema( + tags=['Dashboard - Ig'], + description="Leave Interest Group instantly.", + responses={200: InterestGroupSerializer}, + ) + def delete(self, request, pk): + user_id = JWTUtils.fetch_user_id(request) + + link = UserIgLink.objects.filter( + user_id=user_id, + ig_id=pk, + assignment_type=UserIgLink.AssignmentType.LEARNER + ).first() + + if not link or not link.is_active: + return CustomResponse(general_message="Already left this Interest Group").get_success_response() + + link.is_active = False + link.unassigned_at = DateTimeUtils.get_current_utc_time() + link.save(update_fields=["is_active", "unassigned_at"]) + + + return CustomResponse(general_message="Left Interest Group successfully").get_success_response() \ No newline at end of file diff --git a/api/dashboard/ig/services.py b/api/dashboard/ig/services.py new file mode 100644 index 000000000..a5abbc473 --- /dev/null +++ b/api/dashboard/ig/services.py @@ -0,0 +1,32 @@ +from django.db.models import Count, Q +from db.task import InterestGroup, UserIgLink +from utils.types import OrganizationType + +def get_campus_igs(org_id: str): + """ + Returns active Interest Groups within a campus based on actual student membership. + Enforces tenant isolation by strictly filtering user_ig_link by the given org_id. + """ + return InterestGroup.objects.filter( + user_ig_link_ig__user__user_organization_link_user__org_id=org_id, + user_ig_link_ig__user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value + ).annotate( + campus_member_count=Count( + 'user_ig_link_ig__user', + distinct=True, + filter=Q( + user_ig_link_ig__user__user_organization_link_user__org_id=org_id, + user_ig_link_ig__user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value + ) + ) + ).distinct() + +def get_top_igs(org_id: str): + return get_campus_igs(org_id).order_by('-campus_member_count') + +def get_ig_members(ig_id: str, org_id: str): + return UserIgLink.objects.filter( + ig_id=ig_id, + user__user_organization_link_user__org_id=org_id, + user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value + ).select_related('user', 'user__wallet_user', 'user__user_lvl_link_user__level') diff --git a/api/dashboard/ig/urls.py b/api/dashboard/ig/urls.py index ee69cfa49..c105cec93 100644 --- a/api/dashboard/ig/urls.py +++ b/api/dashboard/ig/urls.py @@ -4,8 +4,12 @@ urlpatterns = [ path('', dash_ig_view.InterestGroupAPI.as_view()), # for get data and create new interest groups + path('request/', dash_ig_view.InterestGroupRequestAPI.as_view()), # for submitting IG creation requests + path('request//', dash_ig_view.InterestGroupRequestAPI.as_view()), # PATCH: admin status update | DELETE: cancel request path('list/', dash_ig_view.InterestGroupListApi.as_view()), # for public listing without admin permission path('csv/', dash_ig_view.InterestGroupCSV.as_view()), # for IG data CSV download path('/', dash_ig_view.InterestGroupAPI.as_view()), # for edit and delete path('get//', dash_ig_view.InterestGroupGetAPI.as_view()), # for edit and delete + path('/join/', dash_ig_view.InterestGroupMembershipAPI.as_view()), + path('/leave/', dash_ig_view.InterestGroupMembershipAPI.as_view()), ] diff --git a/api/dashboard/intern/leaderboard/leaderboard_views.py b/api/dashboard/intern/leaderboard/leaderboard_views.py new file mode 100644 index 000000000..79861c40b --- /dev/null +++ b/api/dashboard/intern/leaderboard/leaderboard_views.py @@ -0,0 +1,217 @@ +from rest_framework.views import APIView +from django.db.models import Sum, Count +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternLeaderboardWeights, InternGuildStatus +from db.intern import UserInternGuildLink, InternTask, InternDailyTimesheet, InternWeeklyReview +from db.task import KarmaActivityLog +from db.achievement import UserStreak + +class InternLeaderboardAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value, RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve the full paginated intern leaderboard with scores and ranks.", + responses={200: OpenApiResponse(description="Paginated leaderboard data with rank and score.")}, + ) + def get(self, request): + interns = UserInternGuildLink.objects.filter( + status__in=[ + InternGuildStatus.ACTIVE.value, + InternGuildStatus.AT_RISK.value, + InternGuildStatus.ON_LEAVE.value, + ] + ).select_related('user') + + intern_user_ids = [intern.user_id for intern in interns] + + streaks = UserStreak.objects.filter(user_id__in=intern_user_ids) + daily_streaks = {s.user_id: s.current_streak for s in streaks if s.streak_type == 'intern_timesheet'} + weekly_streaks = {s.user_id: s.current_streak for s in streaks if s.streak_type == 'intern_weekly_review'} + + karma_logs = KarmaActivityLog.objects.filter(user_id__in=intern_user_ids, task__hashtag__startswith='#intern-') + karma_by_user = karma_logs.values('user_id').annotate(total=Sum('karma')) + karma_dict = {item['user_id']: item['total'] for item in karma_by_user} + + completed_tasks = InternTask.objects.filter(assigned_to_id__in=intern_user_ids, status='COMPLETED') + tasks_by_user = {} + for t in completed_tasks: + tasks_by_user.setdefault(t.assigned_to_id, []).append(t) + + approved_daily_timesheets = InternDailyTimesheet.objects.filter(user_id__in=intern_user_ids, status='APPROVED').values('user_id').annotate(total=Count('id')) + daily_ts_counts = {item['user_id']: item['total'] for item in approved_daily_timesheets} + + approved_weekly_reviews = InternWeeklyReview.objects.filter(user_id__in=intern_user_ids, status='APPROVED').values('user_id').annotate(total=Count('id')) + weekly_rv_counts = {item['user_id']: item['total'] for item in approved_weekly_reviews} + + verified_tasks = InternTask.objects.filter(assigned_to_id__in=intern_user_ids, is_verified=True) + verified_tasks_by_user = {} + for t in verified_tasks: + verified_tasks_by_user.setdefault(t.assigned_to_id, []).append(t) + + + leaderboard_data = [] + for intern in interns: + user_id = intern.user_id + + d_streak_val = daily_streaks.get(user_id, 0) + w_streak_val = weekly_streaks.get(user_id, 0) + total_intern_karma = karma_dict.get(user_id, 0) + + user_tasks = tasks_by_user.get(user_id, []) + completed_count = len(user_tasks) + + complexity_map = {'LOW': 1, 'MEDIUM': 2, 'HIGH': 3, 'CRITICAL': 5} + complexity_score = sum([complexity_map.get(t.complexity, 1) for t in user_tasks]) + + approved_daily_timesheets_count = daily_ts_counts.get(user_id, 0) + approved_weekly_reviews_count = weekly_rv_counts.get(user_id, 0) + + user_verified_tasks = verified_tasks_by_user.get(user_id, []) + verified_intern_tasks_score = sum( + [t.karma_awarded * complexity_map.get(t.complexity, 1) for t in user_verified_tasks] + ) + + score = ( + approved_daily_timesheets_count * 25 + + approved_weekly_reviews_count * 50 + + verified_intern_tasks_score + ) + + leaderboard_data.append({ + "user_id": user_id, + "full_name": intern.user.full_name, + "guild": intern.guild, + "score": score, + "daily_streak": d_streak_val, + "weekly_streak": w_streak_val + }) + + leaderboard_data.sort(key=lambda x: x['score'], reverse=True) + + for i, entry in enumerate(leaderboard_data): + entry['rank'] = i + 1 + + page = int(request.GET.get('page', 1)) + page_size = int(request.GET.get('page_size', 10)) + start = (page - 1) * page_size + end = start + page_size + + paginated_data = leaderboard_data[start:end] + + return CustomResponse( + response={ + "data": paginated_data, + "pagination": { + "count": len(leaderboard_data), + "totalPages": (len(leaderboard_data) + page_size - 1) // page_size, + "isNext": end < len(leaderboard_data), + "isPrev": start > 0, + "nextPage": page + 1 if end < len(leaderboard_data) else None + } + } + ).get_success_response() + +class InternLeaderboardMeAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve the current intern's rank and score on the leaderboard.", + responses={200: OpenApiResponse(description="Intern rank and score.")}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + interns = UserInternGuildLink.objects.filter( + status__in=[ + InternGuildStatus.ACTIVE.value, + InternGuildStatus.AT_RISK.value, + InternGuildStatus.ON_LEAVE.value, + ] + ) + + intern_user_ids = [intern.user_id for intern in interns] + + streaks = UserStreak.objects.filter(user_id__in=intern_user_ids) + daily_streaks = {s.user_id: s.current_streak for s in streaks if s.streak_type == 'intern_timesheet'} + weekly_streaks = {s.user_id: s.current_streak for s in streaks if s.streak_type == 'intern_weekly_review'} + + karma_logs = KarmaActivityLog.objects.filter(user_id__in=intern_user_ids, task__hashtag__startswith='#intern-') + karma_by_user = karma_logs.values('user_id').annotate(total=Sum('karma')) + karma_dict = {item['user_id']: item['total'] for item in karma_by_user} + + completed_tasks = InternTask.objects.filter(assigned_to_id__in=intern_user_ids, status='COMPLETED') + tasks_by_user = {} + for t in completed_tasks: + tasks_by_user.setdefault(t.assigned_to_id, []).append(t) + + approved_daily_timesheets = InternDailyTimesheet.objects.filter(user_id__in=intern_user_ids, status='APPROVED').values('user_id').annotate(total=Count('id')) + daily_ts_counts = {item['user_id']: item['total'] for item in approved_daily_timesheets} + + approved_weekly_reviews = InternWeeklyReview.objects.filter(user_id__in=intern_user_ids, status='APPROVED').values('user_id').annotate(total=Count('id')) + weekly_rv_counts = {item['user_id']: item['total'] for item in approved_weekly_reviews} + + verified_tasks = InternTask.objects.filter(assigned_to_id__in=intern_user_ids, is_verified=True) + verified_tasks_by_user = {} + for t in verified_tasks: + verified_tasks_by_user.setdefault(t.assigned_to_id, []).append(t) + + leaderboard_data = [] + for intern in interns: + uid = intern.user_id + + d_streak_val = daily_streaks.get(uid, 0) + w_streak_val = weekly_streaks.get(uid, 0) + total_intern_karma = karma_dict.get(uid, 0) + + user_tasks = tasks_by_user.get(uid, []) + completed_count = len(user_tasks) + + complexity_map = {'LOW': 1, 'MEDIUM': 2, 'HIGH': 3, 'CRITICAL': 5} + complexity_score = sum([complexity_map.get(t.complexity, 1) for t in user_tasks]) + + approved_daily_timesheets_count = daily_ts_counts.get(uid, 0) + approved_weekly_reviews_count = weekly_rv_counts.get(uid, 0) + + user_verified_tasks = verified_tasks_by_user.get(uid, []) + verified_intern_tasks_score = sum( + [t.karma_awarded * complexity_map.get(t.complexity, 1) for t in user_verified_tasks] + ) + + score = ( + approved_daily_timesheets_count * 25 + + approved_weekly_reviews_count * 50 + + verified_intern_tasks_score + ) + + leaderboard_data.append({ + "user_id": uid, + "score": score, + "daily_streak": d_streak_val, + "weekly_streak": w_streak_val + }) + + leaderboard_data.sort(key=lambda x: x['score'], reverse=True) + + rank = -1 + score = 0 + daily_streak = 0 + weekly_streak = 0 + for i, entry in enumerate(leaderboard_data): + if entry['user_id'] == user_id: + rank = i + 1 + score = entry['score'] + daily_streak = entry['daily_streak'] + weekly_streak = entry['weekly_streak'] + break + + if rank == -1: + return CustomResponse(general_message="Not found in leaderboard.").get_failure_response() + + return CustomResponse(response={"rank": rank, "score": score, "daily_streak": daily_streak, "weekly_streak": weekly_streak}).get_success_response() diff --git a/api/dashboard/intern/leaderboard/urls.py b/api/dashboard/intern/leaderboard/urls.py new file mode 100644 index 000000000..f1ce54274 --- /dev/null +++ b/api/dashboard/intern/leaderboard/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import leaderboard_views + +urlpatterns = [ + path("", leaderboard_views.InternLeaderboardAPI.as_view(), name="intern-leaderboard"), + path("me/", leaderboard_views.InternLeaderboardMeAPI.as_view(), name="intern-leaderboard-me"), +] diff --git a/api/dashboard/intern/leave/leave_views.py b/api/dashboard/intern/leave/leave_views.py new file mode 100644 index 000000000..57be55fcb --- /dev/null +++ b/api/dashboard/intern/leave/leave_views.py @@ -0,0 +1,149 @@ +from django.utils.timezone import now +from datetime import timedelta +from rest_framework.views import APIView +from django.db.models import Sum +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternLeaveStatus +from utils.utils import CommonUtils +from db.intern import InternLeaveRequest + +from .serializers import InternLeaveRequestSerializer, InternLeaveHistorySerializer + +class InternLeaveRequestAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve intern leave request(s). Pass a leave_id to retrieve a specific request.", + responses={200: InternLeaveHistorySerializer}, + ) + def get(self, request, leave_id=None): + user_id = JWTUtils.fetch_user_id(request) + if leave_id: + leave = InternLeaveRequest.objects.filter(id=leave_id, user_id=user_id).first() + if not leave: + return CustomResponse(general_message="Leave request not found.").get_failure_response() + serializer = InternLeaveHistorySerializer(leave) + return CustomResponse(response=serializer.data).get_success_response() + + leaves = InternLeaveRequest.objects.filter(user_id=user_id).order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + leaves, request, + ['leave_type', 'status'], + {'created_at': 'created_at', 'status': 'status'} + ) + + serializer = InternLeaveHistorySerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Submit a new intern leave request.", + request=InternLeaveRequestSerializer, + responses={200: OpenApiResponse(description="Leave request submitted successfully.")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + serializer = InternLeaveRequestSerializer(data=request.data, context={'user_id': user_id}) + + if serializer.is_valid(): + serializer.save() + + from db.mentor import SystemActionLog + SystemActionLog.objects.create( + action_type=SystemActionLog.ActionType.INTERN_LEAVE_REQUEST.value, + actor_user_id=user_id, + subject_user_id=user_id, + entity_name='intern_leave_request', + entity_id=serializer.instance.id, + new_data=serializer.data + ) + + return CustomResponse(general_message="Leave request submitted successfully.").get_success_response() + return CustomResponse(response=serializer.errors).get_failure_response() + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Cancel a pending intern leave request.", + responses={200: OpenApiResponse(description="Leave request cancelled.")}, + ) + def patch(self, request, leave_id=None): + user_id = JWTUtils.fetch_user_id(request) + leave = InternLeaveRequest.objects.filter(id=leave_id, user_id=user_id, status=InternLeaveStatus.PENDING.value).first() + + if not leave: + return CustomResponse(general_message="Pending leave request not found.").get_failure_response() + + action = request.data.get("action") + if action == "cancel": + leave.status = InternLeaveStatus.CANCELLED.value + leave.save() + return CustomResponse(general_message="Leave request cancelled.").get_success_response() + + return CustomResponse(general_message="Invalid action.").get_failure_response() + +class InternLeaveHistoryAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve paginated intern leave history.", + responses={200: InternLeaveHistorySerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + leaves = InternLeaveRequest.objects.filter(user_id=user_id).order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + leaves, request, + ['leave_type', 'status'], + {'created_at': 'created_at', 'status': 'status'} + ) + + serializer = InternLeaveHistorySerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + +class InternLeaveBalanceAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve the intern's total approved leave days used per leave type.", + responses={200: OpenApiResponse(description="Approved leave days used by type.")}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + from django.db.models import Sum + + approved = InternLeaveRequest.objects.filter( + user_id=user_id, + status=InternLeaveStatus.APPROVED.value, + ) + + data = {} + for leave_type in ['SICK', 'CASUAL', 'WFH', 'EMERGENCY']: + used = approved.filter(leave_type=leave_type).aggregate( + Sum('duration_days') + )['duration_days__sum'] or 0 + data[leave_type] = {'used': used} + + return CustomResponse(response=data).get_success_response() diff --git a/api/dashboard/intern/leave/serializers.py b/api/dashboard/intern/leave/serializers.py new file mode 100644 index 000000000..83ee4a242 --- /dev/null +++ b/api/dashboard/intern/leave/serializers.py @@ -0,0 +1,81 @@ +from rest_framework import serializers +from django.utils.timezone import now +from datetime import timedelta +from db.intern import InternLeaveRequest, UserInternGuildLink +from utils.types import InternLeaveStatus, InternLeaveType, InternGuildStatus + +MAX_LEAVE_DURATION_DAYS = 30 +MAX_DAYS_IN_PAST = 30 +MAX_DAYS_IN_FUTURE = 90 + +class InternLeaveRequestSerializer(serializers.ModelSerializer): + leave_type = serializers.ChoiceField(choices=[t.value for t in InternLeaveType]) + + class Meta: + model = InternLeaveRequest + fields = ['leave_type', 'start_date', 'end_date', 'reason', 'duration_days'] + extra_kwargs = { + 'duration_days': {'required': False, 'allow_null': True} + } + + def validate(self, data): + start_date = data.get('start_date') + end_date = data.get('end_date') + today = now().date() + user_id = self.context.get('user_id') + + # Active intern check + if user_id: + guild_link = UserInternGuildLink.objects.filter(user_id=user_id).first() + if not guild_link or guild_link.status == InternGuildStatus.INACTIVE.value: + raise serializers.ValidationError("You must be an active intern to request leave.") + + if start_date and end_date: + if start_date > end_date: + raise serializers.ValidationError({"start_date": "Start date must be before or equal to end date."}) + + # Date range limits + if start_date < today - timedelta(days=MAX_DAYS_IN_PAST): + raise serializers.ValidationError({"start_date": f"Leave cannot be requested more than {MAX_DAYS_IN_PAST} days in the past."}) + if end_date > today + timedelta(days=MAX_DAYS_IN_FUTURE): + raise serializers.ValidationError({"end_date": f"Leave cannot be requested more than {MAX_DAYS_IN_FUTURE} days in the future."}) + + # Duration limit + duration = (end_date - start_date).days + 1 + if duration > MAX_LEAVE_DURATION_DAYS: + raise serializers.ValidationError( + {"end_date": f"A single leave request cannot exceed {MAX_LEAVE_DURATION_DAYS} consecutive days."} + ) + + if user_id: + overlapping_leaves = InternLeaveRequest.objects.filter( + user_id=user_id, + status__in=[InternLeaveStatus.PENDING.value, InternLeaveStatus.APPROVED.value], + start_date__lte=end_date, + end_date__gte=start_date + ).exists() + + if overlapping_leaves: + raise serializers.ValidationError( + {"date_range": "You already have a pending or approved leave request during this period."} + ) + + if 'duration_days' not in data or data.get('duration_days') is None: + data['duration_days'] = duration + return data + + def create(self, validated_data): + user_id = self.context.get('user_id') + validated_data['user_id'] = user_id + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + validated_data['status'] = InternLeaveStatus.PENDING.value + return super().create(validated_data) + +class InternLeaveHistorySerializer(serializers.ModelSerializer): + class Meta: + model = InternLeaveRequest + fields = [ + 'id', 'leave_type', 'start_date', 'end_date', 'reason', + 'status', 'review_note', 'created_at' + ] diff --git a/api/dashboard/intern/leave/urls.py b/api/dashboard/intern/leave/urls.py new file mode 100644 index 000000000..24933f557 --- /dev/null +++ b/api/dashboard/intern/leave/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import leave_views + +urlpatterns = [ + path("", leave_views.InternLeaveRequestAPI.as_view(), name="intern-leave-request"), + path("history/", leave_views.InternLeaveHistoryAPI.as_view(), name="intern-leave-history"), + path("balance/", leave_views.InternLeaveBalanceAPI.as_view(), name="intern-leave-balance"), + path("/", leave_views.InternLeaveRequestAPI.as_view(), name="intern-leave-detail"), + path("/cancel/", leave_views.InternLeaveRequestAPI.as_view(), name="intern-leave-cancel"), +] diff --git a/api/dashboard/intern/minutes/__init__.py b/api/dashboard/intern/minutes/__init__.py new file mode 100644 index 000000000..752f2d072 --- /dev/null +++ b/api/dashboard/intern/minutes/__init__.py @@ -0,0 +1 @@ +# minutes module diff --git a/api/dashboard/intern/minutes/minutes_views.py b/api/dashboard/intern/minutes/minutes_views.py new file mode 100644 index 000000000..2a401bbbc --- /dev/null +++ b/api/dashboard/intern/minutes/minutes_views.py @@ -0,0 +1,157 @@ +import uuid + +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.intern import InternGuildMinute + +from .serializers import InternGuildMinuteSerializer, InternGuildMinuteCreateUpdateSerializer + +# Roles that are allowed to manage (create/update/delete) guild minutes +INTERN_LEAD_ROLES = [RoleType.INTERN_LEAD.value, RoleType.ADMIN.value] + + +class InternGuildMinuteAPI(APIView): + """ + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + """ + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value, RoleType.INTERN_LEAD.value, RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description=( + "List guild minutes. Supports optional query filters:\n" + "- `guild`: Filter by guild name (e.g. 'Frontend Guild')\n" + "- `date`: Filter by exact date (YYYY-MM-DD)\n" + "- `page` / `perPage` / `sortBy` for pagination." + ), + parameters=[ + OpenApiParameter(name='guild', description='Guild name filter', required=False, type=str), + OpenApiParameter(name='date', description='Date filter (YYYY-MM-DD)', required=False, type=str), + ], + responses={200: InternGuildMinuteSerializer(many=True)}, + ) + def get(self, request, minute_id=None): + if minute_id: + minute = InternGuildMinute.objects.filter(id=minute_id).first() + if not minute: + return CustomResponse(general_message="Guild minute not found.").get_failure_response() + serializer = InternGuildMinuteSerializer(minute) + return CustomResponse(response=serializer.data).get_success_response() + + queryset = InternGuildMinute.objects.select_related('created_by').order_by('-date', 'guild') + + # Optional filters + guild = request.query_params.get('guild') + date = request.query_params.get('date') + + if guild: + queryset = queryset.filter(guild=guild) + if date: + queryset = queryset.filter(date=date) + + paginated_queryset = CommonUtils.get_paginated_queryset( + queryset, request, + search_fields=['guild', 'title'], + sort_fields={'date': 'date', 'guild': 'guild'}, + ) + + serializer = InternGuildMinuteSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + @role_required(INTERN_LEAD_ROLES) + @extend_schema( + tags=['Dashboard - Intern'], + description="Upload daily guild minutes. Restricted to Intern Lead and Admins.", + request=InternGuildMinuteCreateUpdateSerializer, + responses={ + 200: OpenApiResponse(description="Guild minutes uploaded successfully."), + 400: OpenApiResponse(description="Validation error."), + }, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + serializer = InternGuildMinuteCreateUpdateSerializer(data=request.data) + + if not serializer.is_valid(): + return CustomResponse(response=serializer.errors).get_failure_response() + + data = serializer.validated_data + minute = InternGuildMinute.objects.create( + id=str(uuid.uuid4()), + guild=data['guild'], + date=data['date'], + title=data['title'], + minutes=data['minutes'], + created_by_id=user_id, + updated_by_id=user_id, + ) + + return CustomResponse( + general_message="Guild minutes uploaded successfully.", + response={"id": str(minute.id)}, + ).get_success_response() + + @role_required(INTERN_LEAD_ROLES) + @extend_schema( + tags=['Dashboard - Intern'], + description="Edit existing guild minutes. Restricted to Intern Lead and Admins.", + request=InternGuildMinuteCreateUpdateSerializer, + responses={ + 200: OpenApiResponse(description="Guild minutes updated successfully."), + 404: OpenApiResponse(description="Minute record not found."), + }, + ) + def put(self, request, minute_id): + user_id = JWTUtils.fetch_user_id(request) + minute = InternGuildMinute.objects.filter(id=minute_id).first() + + if not minute: + return CustomResponse(general_message="Guild minute not found.").get_failure_response() + + serializer = InternGuildMinuteCreateUpdateSerializer(data=request.data, context={'instance_id': minute.id}) + if not serializer.is_valid(): + return CustomResponse(response=serializer.errors).get_failure_response() + + data = serializer.validated_data + minute.guild = data['guild'] + minute.date = data['date'] + minute.title = data['title'] + minute.minutes = data['minutes'] + minute.updated_by_id = user_id + minute.save() + + return CustomResponse(general_message="Guild minutes updated successfully.").get_success_response() + + @role_required(INTERN_LEAD_ROLES) + @extend_schema( + tags=['Dashboard - Intern'], + description="Delete guild minutes. Restricted to Intern Lead and Admins.", + responses={ + 200: OpenApiResponse(description="Guild minutes deleted successfully."), + 404: OpenApiResponse(description="Minute record not found."), + }, + ) + def delete(self, request, minute_id): + minute = InternGuildMinute.objects.filter(id=minute_id).first() + + if not minute: + return CustomResponse(general_message="Guild minute not found.").get_failure_response() + + minute.delete() + return CustomResponse(general_message="Guild minutes deleted successfully.").get_success_response() diff --git a/api/dashboard/intern/minutes/serializers.py b/api/dashboard/intern/minutes/serializers.py new file mode 100644 index 000000000..8b5f8b30c --- /dev/null +++ b/api/dashboard/intern/minutes/serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers + +from db.intern import InternGuildMinute +from utils.types import InternGuild + + +class InternGuildMinuteSerializer(serializers.ModelSerializer): + """Serializer for reading guild minutes (GET responses).""" + created_by_name = serializers.SerializerMethodField() + + class Meta: + model = InternGuildMinute + fields = [ + 'id', 'guild', 'date', 'title', 'minutes', + 'created_by_name', 'created_at', 'updated_at', + ] + + def get_created_by_name(self, obj): + return obj.created_by.full_name if obj.created_by else None + + +class InternGuildMinuteCreateUpdateSerializer(serializers.Serializer): + """Serializer for validating incoming POST/PUT data.""" + guild = serializers.ChoiceField(choices=InternGuild.get_all_values()) + date = serializers.DateField() + title = serializers.CharField(max_length=200) + minutes = serializers.CharField() + + def validate_minutes(self, value): + if not value.strip(): + raise serializers.ValidationError("Minutes content cannot be blank.") + return value + + def validate(self, data): + guild = data.get('guild') + date = data.get('date') + # On create (no instance), check for existing record for same guild+date + instance_id = self.context.get('instance_id') + if guild and date: + qs = InternGuildMinute.objects.filter(guild=guild, date=date) + if instance_id: + qs = qs.exclude(id=instance_id) + if qs.exists(): + raise serializers.ValidationError( + {"date": f"Guild minutes for '{guild}' on {date} already exist."} + ) + return data diff --git a/api/dashboard/intern/minutes/urls.py b/api/dashboard/intern/minutes/urls.py new file mode 100644 index 000000000..8e9f48764 --- /dev/null +++ b/api/dashboard/intern/minutes/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import minutes_views + +urlpatterns = [ + path("", minutes_views.InternGuildMinuteAPI.as_view(), name="intern-guild-minutes-list"), + path("/", minutes_views.InternGuildMinuteAPI.as_view(), name="intern-guild-minutes-detail"), +] diff --git a/api/dashboard/intern/overview/overview_views.py b/api/dashboard/intern/overview/overview_views.py new file mode 100644 index 000000000..81a954487 --- /dev/null +++ b/api/dashboard/intern/overview/overview_views.py @@ -0,0 +1,200 @@ +from rest_framework.views import APIView +from django.db.models import Sum, Count +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternLeaderboardWeights, InternGuildStatus +from utils.utils import CommonUtils +from db.intern import UserInternGuildLink, InternTask, InternDailyTimesheet, InternWeeklyReview +from db.task import KarmaActivityLog +from db.achievement import UserStreak + +class InternOverviewStatusAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve intern overview status including karma, streaks, tasks, and score.", + responses={200: OpenApiResponse(description="Intern status overview data.")}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + guild_link = UserInternGuildLink.objects.filter(user_id=user_id).first() + + if not guild_link: + return CustomResponse(general_message="Not an intern.").get_failure_response() + + daily_streak = UserStreak.objects.filter(user_id=user_id, streak_type='intern_timesheet').first() + weekly_streak = UserStreak.objects.filter(user_id=user_id, streak_type='intern_weekly_review').first() + + intern_karma_logs = KarmaActivityLog.objects.filter(user_id=user_id, task__hashtag__startswith='#intern-') + total_intern_karma = intern_karma_logs.aggregate(Sum('karma'))['karma__sum'] or 0 + + completed_tasks = InternTask.objects.filter(assigned_to_id=user_id, status='COMPLETED').count() + + tasks = InternTask.objects.filter(assigned_to_id=user_id, status='COMPLETED') + complexity_map = {'LOW': 1, 'MEDIUM': 2, 'HIGH': 3, 'CRITICAL': 5} + complexity_score = sum([complexity_map.get(t.complexity, 1) for t in tasks]) + + d_streak_val = daily_streak.current_streak if daily_streak else 0 + w_streak_val = weekly_streak.current_streak if weekly_streak else 0 + d_longest_streak = daily_streak.longest_streak if daily_streak else 0 + w_longest_streak = weekly_streak.longest_streak if weekly_streak else 0 + + # Score formula — identical to both leaderboard endpoints: + # approved_daily_timesheets × 25 + approved_weekly_reviews × 50 + # + sum(karma_awarded × complexity_weight) for each verified intern task + approved_daily_count = InternDailyTimesheet.objects.filter( + user_id=user_id, status='APPROVED' + ).count() + approved_weekly_count = InternWeeklyReview.objects.filter( + user_id=user_id, status='APPROVED' + ).count() + verified_tasks = InternTask.objects.filter(assigned_to_id=user_id, is_verified=True) + verified_tasks_score = sum( + t.karma_awarded * complexity_map.get(t.complexity, 1) + for t in verified_tasks + ) + + score = ( + approved_daily_count * 25 + + approved_weekly_count * 50 + + verified_tasks_score + ) + + total_interns = UserInternGuildLink.objects.count() + + data = { + "guild": guild_link.guild, + "status": guild_link.status, + "join_date": guild_link.created_at.date().isoformat() if guild_link.created_at else None, + "total_intern_karma": total_intern_karma, + "daily_streak": d_streak_val, + "longest_daily_streak": d_longest_streak, + "weekly_streak": w_streak_val, + "longest_weekly_streak": w_longest_streak, + "completed_tasks": completed_tasks, + "complexity_score": complexity_score, + "score": score, + "total_interns": total_interns + } + return CustomResponse(response=data).get_success_response() + + +class InternOverviewActivityAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve paginated intern karma activity log.", + responses={200: OpenApiResponse(description="List of intern karma activity entries.")}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + logs = KarmaActivityLog.objects.filter( + user_id=user_id, + task__hashtag__startswith='#intern-' + ).select_related('task').order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + logs, request, + ['task__title'], + {'created_at': 'created_at'} + ) + + data = [{ + "id": log.id, + "task_title": log.task.title, + "karma": log.karma, + "created_at": log.created_at + } for log in paginated_queryset.get("queryset")] + + return CustomResponse( + response={ + "data": data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + +class InternOverviewLeaderboardTopAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve the top 3 interns on the leaderboard.", + responses={200: OpenApiResponse(description="Top 3 intern leaderboard entries.")}, + ) + def get(self, request): + from django.db.models import Count, Sum + from db.intern import InternDailyTimesheet, InternWeeklyReview + + interns = UserInternGuildLink.objects.filter( + status__in=[ + InternGuildStatus.ACTIVE.value, + InternGuildStatus.AT_RISK.value, + InternGuildStatus.ON_LEAVE.value, + ] + ).select_related('user') + + intern_user_ids = [intern.user_id for intern in interns] + complexity_map = {'LOW': 1, 'MEDIUM': 2, 'HIGH': 3, 'CRITICAL': 5} + + # Batch fetch all scoring data + daily_ts_counts = dict( + InternDailyTimesheet.objects.filter(user_id__in=intern_user_ids, status='APPROVED') + .values('user_id').annotate(total=Count('id')) + .values_list('user_id', 'total') + ) + weekly_rv_counts = dict( + InternWeeklyReview.objects.filter(user_id__in=intern_user_ids, status='APPROVED') + .values('user_id').annotate(total=Count('id')) + .values_list('user_id', 'total') + ) + verified_tasks = InternTask.objects.filter(assigned_to_id__in=intern_user_ids, is_verified=True) + task_score_by_user = {} + for t in verified_tasks: + task_score_by_user[t.assigned_to_id] = task_score_by_user.get(t.assigned_to_id, 0) + ( + t.karma_awarded * complexity_map.get(t.complexity, 1) + ) + + leaderboard_data = [] + for intern in interns: + uid = intern.user_id + score = ( + daily_ts_counts.get(uid, 0) * 25 + + weekly_rv_counts.get(uid, 0) * 50 + + task_score_by_user.get(uid, 0) + ) + leaderboard_data.append({ + "user_id": uid, + "full_name": intern.user.full_name, + "guild": intern.guild, + "score": score + }) + + leaderboard_data.sort(key=lambda x: x['score'], reverse=True) + top_three = leaderboard_data[:3] + for i, entry in enumerate(top_three): + entry['rank'] = i + 1 + + return CustomResponse(response=top_three).get_success_response() + + +class InternGuildsAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value, RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve all available intern guilds.", + responses={200: OpenApiResponse(description="List of all intern guild values.")}, + ) + def get(self, request): + from utils.types import InternGuild + guilds = InternGuild.get_all_values() + return CustomResponse(response=guilds).get_success_response() diff --git a/api/dashboard/intern/overview/urls.py b/api/dashboard/intern/overview/urls.py new file mode 100644 index 000000000..9a38ee7a5 --- /dev/null +++ b/api/dashboard/intern/overview/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import overview_views + +urlpatterns = [ + path("status/", overview_views.InternOverviewStatusAPI.as_view(), name="intern-overview-status"), + path("activity/", overview_views.InternOverviewActivityAPI.as_view(), name="intern-overview-activity"), + path("leaderboard/top/", overview_views.InternOverviewLeaderboardTopAPI.as_view(), name="intern-overview-leaderboard-top"), +] diff --git a/api/dashboard/intern/tasks/serializers.py b/api/dashboard/intern/tasks/serializers.py new file mode 100644 index 000000000..6fdbc0edd --- /dev/null +++ b/api/dashboard/intern/tasks/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers +from db.intern import InternTask + +COMPLEXITY_WEIGHT_MAP = {'LOW': 1, 'MEDIUM': 2, 'HIGH': 3, 'CRITICAL': 5} + + +class InternTaskSerializer(serializers.ModelSerializer): + assigned_to_name = serializers.CharField(source='assigned_to.full_name', read_only=True) + created_by_name = serializers.CharField(source='created_by.full_name', read_only=True) + complexity_score = serializers.SerializerMethodField() + + class Meta: + model = InternTask + fields = [ + 'id', 'title', 'description', 'category', 'complexity', + 'complexity_score', 'assigned_to', 'assigned_to_name', 'status', + 'remark', 'karma_awarded', 'output_link', 'is_verified', 'verified_by', + 'created_by', 'created_by_name', 'created_at', 'updated_at' + ] + read_only_fields = ['remark'] + + def get_complexity_score(self, obj): + return COMPLEXITY_WEIGHT_MAP.get(obj.complexity, 1) diff --git a/api/dashboard/intern/tasks/tasks_views.py b/api/dashboard/intern/tasks/tasks_views.py new file mode 100644 index 000000000..459ccbece --- /dev/null +++ b/api/dashboard/intern/tasks/tasks_views.py @@ -0,0 +1,133 @@ +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternTaskCategory, InternTaskStatus +from utils.utils import CommonUtils +from db.intern import InternTask +from .serializers import InternTaskSerializer + + +class InternTaskCategoryAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value,RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve all intern task categories and their sub-categories.", + responses={200: OpenApiResponse(description="Task categories retrieved successfully.")}, + ) + def get(self, request): + categories = { + member.name.replace("_", " ").title(): member.value + for member in InternTaskCategory + } + return CustomResponse(response=categories).get_success_response() + + +class InternTaskDetailAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve detail for a specific task assigned to the current intern.", + responses={200: InternTaskSerializer}, + ) + def get(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + task = InternTask.objects.filter(id=task_id, assigned_to_id=user_id).first() + if not task: + return CustomResponse(general_message="Task not found.").get_failure_response() + serializer = InternTaskSerializer(task) + return CustomResponse(response=serializer.data).get_success_response() + + +class InternTaskSubmitAPI(APIView): + """ + Allows an intern to directly set task status and output_link + without going through a timesheet submission. + """ + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description=( + "Directly submit a task update: set status and/or output_link. " + "output_link is required when status=COMPLETED." + ), + responses={200: OpenApiResponse(description="Task submitted successfully.")}, + ) + def patch(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + task = InternTask.objects.filter(id=task_id, assigned_to_id=user_id).first() + + if not task: + return CustomResponse(general_message="Task not found.").get_failure_response() + + if task.is_verified: + return CustomResponse(general_message="Task is already verified and cannot be modified.").get_failure_response() + + status = request.data.get('status') + output_link = request.data.get('output_link') + + if not status and output_link is None: + return CustomResponse(general_message="At least one of status or output_link is required.").get_failure_response() + + editable = InternTaskStatus.intern_editable() + if status and status not in editable: + return CustomResponse(general_message=f"Invalid status '{status}'. Valid values: {editable}").get_failure_response() + + if status == InternTaskStatus.COMPLETED.value: + resolved_link = output_link or task.output_link + if not resolved_link: + return CustomResponse(general_message="output_link is required when marking a task as COMPLETED.").get_failure_response() + + old_data = {'status': task.status, 'output_link': task.output_link} + if status: + task.status = status + if output_link is not None: + task.output_link = output_link + task.save(update_fields=['status', 'output_link'] if output_link is not None else ['status']) + + from db.mentor import SystemActionLog + SystemActionLog.objects.create( + action_type=SystemActionLog.ActionType.INTERN_TASK_UPDATE.value, + actor_user_id=user_id, + subject_user_id=user_id, + entity_name='intern_task', + entity_id=task.id, + old_data=old_data, + new_data={'status': task.status, 'output_link': task.output_link} + ) + + return CustomResponse(general_message="Task status updated successfully.").get_success_response() + +class InternTaskMineAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve all tasks assigned to the current intern.", + responses={200: InternTaskSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + tasks = InternTask.objects.filter(assigned_to_id=user_id).order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + tasks, request, + ['title', 'status'], + {'created_at': 'created_at', 'status': 'status'} + ) + + serializer = InternTaskSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() diff --git a/api/dashboard/intern/tasks/urls.py b/api/dashboard/intern/tasks/urls.py new file mode 100644 index 000000000..ae26f52ab --- /dev/null +++ b/api/dashboard/intern/tasks/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import tasks_views + +urlpatterns = [ + path("categories/", tasks_views.InternTaskCategoryAPI.as_view(), name="intern-tasks-categories"), + path("mine/", tasks_views.InternTaskMineAPI.as_view(), name="intern-tasks-mine"), + # Canonical task update path: PATCH /tasks// + path("/", tasks_views.InternTaskSubmitAPI.as_view(), name="intern-tasks-update"), + # /submit/ kept for backward compat + path("/submit/", tasks_views.InternTaskSubmitAPI.as_view(), name="intern-tasks-submit"), + path("/detail/", tasks_views.InternTaskDetailAPI.as_view(), name="intern-tasks-detail"), +] diff --git a/api/dashboard/intern/timesheet/serializers.py b/api/dashboard/intern/timesheet/serializers.py new file mode 100644 index 000000000..321fd26e4 --- /dev/null +++ b/api/dashboard/intern/timesheet/serializers.py @@ -0,0 +1,238 @@ +from rest_framework import serializers +from django.utils.timezone import now +from django.db import transaction +from datetime import timedelta + +from db.intern import InternDailyTimesheet, InternTask +from utils.types import InternSubmissionStatus, InternTaskStatus + + +class TaskUpdateEntrySerializer(serializers.Serializer): + """Validates a single task entry inside the `task` JSON array.""" + task_id = serializers.CharField() + status = serializers.ChoiceField( + choices=[s.value for s in InternTaskStatus] + ) + remark = serializers.CharField(required=False, allow_blank=True, allow_null=True) + + +class InternTimesheetSerializer(serializers.ModelSerializer): + task = TaskUpdateEntrySerializer(many=True, required=False, allow_null=True) + description = serializers.CharField(max_length=2000) + blockers = serializers.CharField(max_length=1000, required=False, allow_blank=True, allow_null=True) + end_of_day_note = serializers.CharField(max_length=1000, required=False, allow_blank=True, allow_null=True) + edit_reason = serializers.CharField(max_length=300, required=False, allow_blank=True, allow_null=True) + + class Meta: + model = InternDailyTimesheet + fields = [ + 'entry_date', 'task', 'description', + 'hours', 'blockers', 'end_of_day_note', 'edit_reason', + ] + + def validate(self, data): + entry_date = data.get('entry_date') + today = now().date() + yesterday = today - timedelta(days=1) + user_id = self.context.get('user_id') + + if entry_date > today: + raise serializers.ValidationError({"entry_date": "Future dates are not allowed."}) + + if entry_date < yesterday and not data.get('edit_reason'): + raise serializers.ValidationError({"edit_reason": "Reason is required for late submissions."}) + + if not data.get('description'): + raise serializers.ValidationError({"description": "Description is required."}) + + hours = data.get('hours', 0) + if not hours or hours <= 0: + raise serializers.ValidationError({"hours": "Hours must be greater than 0."}) + if hours > 24: + raise serializers.ValidationError({"hours": "Hours cannot exceed 24 in a single day."}) + + # Prevent duplicate timesheet for the same date + if InternDailyTimesheet.objects.filter(user_id=user_id, entry_date=entry_date).exists(): + raise serializers.ValidationError({"entry_date": "A timesheet for this date has already been submitted."}) + + task_entries = data.get('task') or [] + user_id = self.context.get('user_id') + task_ids = [entry.get('task_id') for entry in task_entries if entry.get('task_id')] + + # Bulk fetch tasks + intern_tasks = { + str(t.id): t for t in InternTask.objects.filter(id__in=task_ids, assigned_to_id=user_id) + } + + seen_task_ids = set() + for entry in task_entries: + task_id = entry.get('task_id') + status = entry.get('status') + + # Duplicate task_id in same submission + if task_id in seen_task_ids: + raise serializers.ValidationError( + {"task": f"Duplicate task_id '{task_id}' in submission."} + ) + seen_task_ids.add(task_id) + + # Must belong to this intern + intern_task = intern_tasks.get(str(task_id)) + if not intern_task: + raise serializers.ValidationError( + {"task": f"Task '{task_id}' is not assigned to you or does not exist."} + ) + + # Cannot modify verified task + if intern_task.is_verified: + raise serializers.ValidationError( + {"task": f"Task '{intern_task.title}' is already verified and cannot be modified."} + ) + + # COMPLETED requires output_link to already be set + if status == InternTaskStatus.COMPLETED.value and not intern_task.output_link: + raise serializers.ValidationError( + {"task": f"Task '{intern_task.title}' requires an output_link before marking COMPLETED. Use the task submit endpoint first."} + ) + + # Enrich entry with title for the snapshot + entry['title'] = intern_task.title + + return data + + def create(self, validated_data): + user_id = self.context.get('user_id') + task_entries = validated_data.pop('task', None) or [] + + with transaction.atomic(): + # Update InternTask.status for each entry + for entry in task_entries: + task_id = entry.get('task_id') + new_status = entry.get('status') + InternTask.objects.filter(id=task_id, assigned_to_id=user_id).update(status=new_status) + + # Store clean snapshot (task_id, title, status, remark) + snapshot = [ + { + 'task_id': e['task_id'], + 'title': e.get('title', ''), + 'status': e['status'], + 'remark': e.get('remark') or '', + } + for e in task_entries + ] or None + + validated_data['task'] = snapshot + validated_data['user_id'] = user_id + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + validated_data['status'] = InternSubmissionStatus.PENDING.value + + return InternDailyTimesheet.objects.create(**validated_data) + + +class InternTimesheetHistorySerializer(serializers.ModelSerializer): + # Points awarded to the leaderboard score for this timesheet entry. + # Only APPROVED timesheets count (25 pts each); all others earn 0. + score = serializers.SerializerMethodField() + + class Meta: + model = InternDailyTimesheet + fields = [ + 'id', 'entry_date', 'task', 'description', + 'hours', 'blockers', 'end_of_day_note', 'edit_reason', + 'status', 'review_note', 'created_at', 'score' + ] + + def get_score(self, obj): + return 25 if obj.status == 'APPROVED' else 0 + + +class InternTimesheetEditSerializer(serializers.ModelSerializer): + task = TaskUpdateEntrySerializer(many=True, required=False, allow_null=True) + description = serializers.CharField(max_length=2000, required=False) + blockers = serializers.CharField(max_length=1000, required=False, allow_blank=True, allow_null=True) + end_of_day_note = serializers.CharField(max_length=1000, required=False, allow_blank=True, allow_null=True) + edit_reason = serializers.CharField(max_length=300, required=True, allow_blank=False, allow_null=False) + entry_date = serializers.DateField(required=False) + hours = serializers.DecimalField(max_digits=4, decimal_places=2, required=False) + + class Meta: + model = InternDailyTimesheet + fields = [ + 'entry_date', 'task', 'description', + 'hours', 'blockers', 'end_of_day_note', 'edit_reason', + ] + + def validate(self, data): + timesheet = self.instance + user_id = self.context.get('user_id') + + # 0. edit_reason is mandatory for all modifications + if 'edit_reason' not in data or not data.get('edit_reason'): + raise serializers.ValidationError({"edit_reason": "edit_reason is mandatory for all modifications."}) + + # 1. Validate entry_date if provided + entry_date = data.get('entry_date') + if entry_date is not None: + today = now().date() + if entry_date > today: + raise serializers.ValidationError({"entry_date": "Future dates are not allowed."}) + + # Check duplicate timesheet on the same date for the same user, excluding current timesheet + if InternDailyTimesheet.objects.filter(user_id=user_id, entry_date=entry_date).exclude(id=timesheet.id).exists(): + raise serializers.ValidationError({"entry_date": "A timesheet for this date has already been submitted."}) + + # 2. Validate description if provided + if 'description' in data and not data.get('description'): + raise serializers.ValidationError({"description": "Description is required."}) + + # 3. Validate hours if provided + if 'hours' in data: + hours = data.get('hours') + if hours is None or hours <= 0: + raise serializers.ValidationError({"hours": "Hours must be greater than 0."}) + if hours > 24: + raise serializers.ValidationError({"hours": "Hours cannot exceed 24 in a single day."}) + + # 4. Validate task entries if provided + if 'task' in data: + task_entries = data.get('task') or [] + task_ids = [entry.get('task_id') for entry in task_entries if entry.get('task_id')] + + # Bulk fetch tasks + intern_tasks = { + str(t.id): t for t in InternTask.objects.filter(id__in=task_ids, assigned_to_id=user_id) + } + + seen_task_ids = set() + for entry in task_entries: + task_id = entry.get('task_id') + status = entry.get('status') + + if task_id in seen_task_ids: + raise serializers.ValidationError( + {"task": f"Duplicate task_id '{task_id}' in submission."} + ) + seen_task_ids.add(task_id) + + intern_task = intern_tasks.get(str(task_id)) + if not intern_task: + raise serializers.ValidationError( + {"task": f"Task '{task_id}' is not assigned to you or does not exist."} + ) + + if intern_task.is_verified: + raise serializers.ValidationError( + {"task": f"Task '{intern_task.title}' is already verified and cannot be modified."} + ) + + if status == InternTaskStatus.COMPLETED.value and not intern_task.output_link: + raise serializers.ValidationError( + {"task": f"Task '{intern_task.title}' requires an output_link before marking COMPLETED. Use the task submit endpoint first."} + ) + + # Enrich entry with title for the snapshot + entry['title'] = intern_task.title + + return data diff --git a/api/dashboard/intern/timesheet/timesheet_views.py b/api/dashboard/intern/timesheet/timesheet_views.py new file mode 100644 index 000000000..5cafc834f --- /dev/null +++ b/api/dashboard/intern/timesheet/timesheet_views.py @@ -0,0 +1,293 @@ +from django.db import IntegrityError, transaction +from django.utils.timezone import now + +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternGuildStatus, InternTaskStatus +from utils.utils import CommonUtils +from db.intern import InternDailyTimesheet, UserInternGuildLink, InternTask +from db.achievement import UserStreak +from db.mentor import SystemActionLog + +from .serializers import InternTimesheetSerializer, InternTimesheetHistorySerializer, InternTimesheetEditSerializer + + +class InternTimesheetPrefillAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description=( + "Retrieve tasks assigned to the intern for the current ISO week. " + "Used to pre-populate the daily timesheet submission form." + ), + responses={200: OpenApiResponse(description="List of this week's tasks for the intern.")}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + guild_link = UserInternGuildLink.objects.filter(user_id=user_id).first() + + if not guild_link or guild_link.status == InternGuildStatus.INACTIVE.value: + return CustomResponse(general_message="Not an active intern.").get_failure_response() + + if guild_link.status == InternGuildStatus.ON_LEAVE.value: + return CustomResponse(response={"tasks": [], "on_leave": True}).get_success_response() + + today = now().date() + _, iso_week, _ = today.isocalendar() + + tasks = InternTask.objects.filter( + assigned_to_id=user_id, + iso_week=iso_week, + is_archived=False, + ).exclude( + status='COMPLETED', is_verified=True + ).order_by('deadline') + + data = [ + { + "task_id": str(t.id), + "title": t.title, + "category": t.category, + "deadline": t.deadline.isoformat(), + "status": t.status, + "complexity": t.complexity, + "output_link": t.output_link, + "is_overdue": t.status == 'OVERDUE', + } + for t in tasks + ] + + return CustomResponse(response={"tasks": data, "on_leave": False}).get_success_response() + + +class InternTimesheetAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve intern timesheet(s). Pass a timesheet_id to retrieve a specific entry.", + responses={200: InternTimesheetHistorySerializer}, + ) + def get(self, request, timesheet_id=None): + user_id = JWTUtils.fetch_user_id(request) + if timesheet_id: + timesheet = InternDailyTimesheet.objects.filter(id=timesheet_id, user_id=user_id).first() + if not timesheet: + return CustomResponse(general_message="Timesheet not found.").get_failure_response() + serializer = InternTimesheetHistorySerializer(timesheet) + return CustomResponse(response=serializer.data).get_success_response() + + timesheets = InternDailyTimesheet.objects.filter(user_id=user_id).order_by('-entry_date') + + paginated_queryset = CommonUtils.get_paginated_queryset( + timesheets, request, + ['entry_date', 'status'], + {'entry_date': 'entry_date', 'status': 'status'} + ) + + serializer = InternTimesheetHistorySerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Submit a new daily timesheet entry.", + request=InternTimesheetSerializer, + responses={200: OpenApiResponse(description="Timesheet submitted successfully.")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + guild_link = UserInternGuildLink.objects.filter(user_id=user_id).first() + + if not guild_link or guild_link.status == InternGuildStatus.INACTIVE.value: + return CustomResponse(general_message="Not an active intern.").get_failure_response() + + if guild_link.status == InternGuildStatus.ON_LEAVE.value: + return CustomResponse(general_message="You are currently on approved leave and cannot submit a timesheet.").get_failure_response() + + serializer = InternTimesheetSerializer(data=request.data, context={'user_id': user_id}) + if serializer.is_valid(): + try: + serializer.save() + return CustomResponse(general_message="Timesheet submitted successfully.").get_success_response() + except IntegrityError: + return CustomResponse(general_message="Timesheet for this date already exists.", status_code=409).get_failure_response() + return CustomResponse(response=serializer.errors).get_failure_response() + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Edit fields on a pending timesheet entry.", + request=InternTimesheetEditSerializer, + responses={200: OpenApiResponse(description="Timesheet updated successfully.")}, + ) + def patch(self, request, timesheet_id): + user_id = JWTUtils.fetch_user_id(request) + + timesheet = InternDailyTimesheet.objects.filter(id=timesheet_id, user_id=user_id).first() + if not timesheet: + return CustomResponse(general_message="Timesheet not found.").get_failure_response() + + if timesheet.status != 'PENDING': + return CustomResponse(general_message="Only works on timesheets with status = PENDING.").get_failure_response() + + serializer = InternTimesheetEditSerializer( + instance=timesheet, + data=request.data, + partial=True, + context={'user_id': user_id} + ) + if not serializer.is_valid(): + return CustomResponse(response=serializer.errors).get_failure_response() + + edit_reason = serializer.validated_data.get("edit_reason") + + old_data = {} + new_data = {} + + for field, new_val in serializer.validated_data.items(): + if field == 'edit_reason': + continue + + if field == 'task': + new_snapshot = [ + { + 'task_id': e['task_id'], + 'title': e.get('title', ''), + 'status': e['status'], + 'remark': e.get('remark') or '', + } + for e in new_val + ] if new_val is not None else None + + old_val = timesheet.task + if old_val != new_snapshot: + old_data[field] = old_val + new_data[field] = new_snapshot + else: + old_val = getattr(timesheet, field) + if old_val != new_val: + if field == 'entry_date': + old_data[field] = str(old_val) if old_val else None + new_data[field] = str(new_val) if new_val else None + elif field == 'hours': + old_data[field] = str(old_val) if old_val else None + new_data[field] = str(new_val) if new_val else None + else: + old_data[field] = old_val + new_data[field] = new_val + + if not new_data: + return CustomResponse(general_message="No editable fields provided or no changes detected.").get_failure_response() + + with transaction.atomic(): + if 'task' in new_data: + old_task_ids = {t['task_id'] for t in (timesheet.task or [])} + new_task_ids = {t['task_id'] for t in new_data['task']} + removed_task_ids = old_task_ids - new_task_ids + if removed_task_ids: + InternTask.objects.filter(id__in=removed_task_ids, assigned_to_id=user_id, is_verified=False).update(status=InternTaskStatus.NOT_STARTED.value) + + for entry in new_data['task']: + task_id = entry.get('task_id') + new_status = entry.get('status') + InternTask.objects.filter(id=task_id, assigned_to_id=user_id).update(status=new_status) + + for field, new_val in new_data.items(): + setattr(timesheet, field, new_val) + + timesheet.edit_reason = edit_reason + timesheet.save() + + SystemActionLog.objects.create( + action_type=SystemActionLog.ActionType.INTERN_TIMESHEET_EDIT.value, + actor_user_id=user_id, + subject_user_id=user_id, + entity_name='intern_daily_timesheet', + entity_id=timesheet.id, + old_data=old_data, + new_data=new_data, + remarks=edit_reason + ) + + return CustomResponse(general_message="Timesheet updated successfully.").get_success_response() + + +class InternTimesheetTodayAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve today's timesheet entry for the current intern.", + responses={200: InternTimesheetHistorySerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + today = now().date() + + timesheet = InternDailyTimesheet.objects.filter(user_id=user_id, entry_date=today).first() + if not timesheet: + return CustomResponse(general_message="No timesheet submitted for today.").get_failure_response() + + serializer = InternTimesheetHistorySerializer(timesheet) + return CustomResponse(response=serializer.data).get_success_response() + + +class InternTimesheetHistoryAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve paginated intern timesheet history.", + responses={200: InternTimesheetHistorySerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + timesheets = InternDailyTimesheet.objects.filter(user_id=user_id).order_by('-entry_date') + + paginated_queryset = CommonUtils.get_paginated_queryset( + timesheets, request, + ['entry_date', 'status'], + {'entry_date': 'entry_date', 'status': 'status'} + ) + + serializer = InternTimesheetHistorySerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + +class InternTimesheetSummaryAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve current timesheet streak stats (current and longest streak).", + responses={200: OpenApiResponse(description="Streak stats for the intern.")}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + streak = UserStreak.objects.filter(user_id=user_id, streak_type='intern_timesheet').first() + + data = { + "current_streak": streak.current_streak if streak else 0, + "longest_streak": streak.longest_streak if streak else 0, + } + return CustomResponse(response=data).get_success_response() diff --git a/api/dashboard/intern/timesheet/urls.py b/api/dashboard/intern/timesheet/urls.py new file mode 100644 index 000000000..3eb8a3fba --- /dev/null +++ b/api/dashboard/intern/timesheet/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import timesheet_views + +urlpatterns = [ + path("", timesheet_views.InternTimesheetAPI.as_view(), name="intern-timesheet-list-create"), + path("prefill/", timesheet_views.InternTimesheetPrefillAPI.as_view(), name="intern-timesheet-prefill"), + path("today/", timesheet_views.InternTimesheetTodayAPI.as_view(), name="intern-timesheet-today"), + path("history/", timesheet_views.InternTimesheetHistoryAPI.as_view(), name="intern-timesheet-history"), + path("summary/", timesheet_views.InternTimesheetSummaryAPI.as_view(), name="intern-timesheet-summary"), + path("/", timesheet_views.InternTimesheetAPI.as_view(), name="intern-timesheet-edit"), +] diff --git a/api/dashboard/intern/urls.py b/api/dashboard/intern/urls.py new file mode 100644 index 000000000..3875cf721 --- /dev/null +++ b/api/dashboard/intern/urls.py @@ -0,0 +1,13 @@ +from django.urls import path, include +from api.dashboard.intern.overview import overview_views + +urlpatterns = [ + path("timesheets/", include("api.dashboard.intern.timesheet.urls")), + path("reviews/", include("api.dashboard.intern.weekly_review.urls")), + path("overview/", include("api.dashboard.intern.overview.urls")), + path("leaderboard/", include("api.dashboard.intern.leaderboard.urls")), + path("tasks/", include("api.dashboard.intern.tasks.urls")), + path("leave/", include("api.dashboard.intern.leave.urls")), + path("guilds/", overview_views.InternGuildsAPI.as_view(), name="intern-guilds"), + path("minutes/", include("api.dashboard.intern.minutes.urls")), +] diff --git a/api/dashboard/intern/weekly_review/serializers.py b/api/dashboard/intern/weekly_review/serializers.py new file mode 100644 index 000000000..6c904b39e --- /dev/null +++ b/api/dashboard/intern/weekly_review/serializers.py @@ -0,0 +1,157 @@ +from rest_framework import serializers +from django.utils.timezone import now +from datetime import timedelta + +from db.intern import InternWeeklyReview, InternTask, UserInternGuildLink +from utils.types import InternSubmissionStatus + + +class InternWeeklyReviewSerializer(serializers.ModelSerializer): + week_start_date = serializers.DateField(required=False, write_only=True) + rating = serializers.IntegerField(required=False, write_only=True, min_value=1, max_value=5) + next_week_plan = serializers.CharField(required=False, write_only=True, allow_blank=True, allow_null=True, max_length=2000) + challenges_faced = serializers.CharField(required=False, write_only=True, allow_blank=True, allow_null=True, max_length=2000) + learnings = serializers.CharField(required=False, write_only=True, allow_blank=True, allow_null=True, max_length=2000) + + class Meta: + model = InternWeeklyReview + fields = [ + 'team', 'is_on_leave', 'tasks_assigned', + 'weekly_review', 'task_remarks', 'hours_committed', 'blockers', + 'leave_days', 'suggestions', 'week_start_date', 'rating', + 'next_week_plan', 'challenges_faced', 'learnings' + ] + extra_kwargs = { + 'team': {'required': False, 'allow_blank': True}, + 'hours_committed': {'required': False, 'allow_null': True}, + 'tasks_assigned': {'required': False, 'allow_null': True}, + 'weekly_review': {'required': False, 'allow_blank': True, 'allow_null': True}, + } + + def validate(self, data): + is_on_leave = data.get('is_on_leave') + if is_on_leave is None and self.instance: + is_on_leave = self.instance.is_on_leave + if is_on_leave is None: + is_on_leave = False + + # Duplicate weekly review check (only on create) + if not self.instance: + user_id = self.context.get('user_id') + target_date = data.get('week_start_date') or now().date() + iso_year, iso_week, _ = target_date.isocalendar() + if InternWeeklyReview.objects.filter(user_id=user_id, iso_year=iso_year, iso_week=iso_week).exists(): + raise serializers.ValidationError( + {"week": f"A weekly review for week {iso_week} of {iso_year} has already been submitted."} + ) + + if not is_on_leave: + if 'hours_committed' in data: + val = data['hours_committed'] + if val is None or val <= 0: + raise serializers.ValidationError({"hours_committed": "Hours must be greater than 0."}) + if val > 168: + raise serializers.ValidationError({"hours_committed": "Hours committed cannot exceed 168 hours per week."}) + elif not self.instance: + raise serializers.ValidationError({"hours_committed": "Hours committed is required when not on leave."}) + else: + if 'hours_committed' in data: + val = data['hours_committed'] + if val is None: + data['hours_committed'] = 0 + elif not self.instance: + data['hours_committed'] = 0 + return data + + def _build_tasks_completed(self, user_id, iso_week): + """Auto-populate tasks_completed JSON from InternTask records for the given week.""" + tasks = InternTask.objects.filter( + assigned_to_id=user_id, + iso_week=iso_week, + status__in=['COMPLETED', 'WAITING_FOR_REVIEW'], + is_archived=False, + ) + return [ + { + 'task_id': str(t.id), + 'title': t.title, + 'category': t.category, + 'complexity': t.complexity, + 'deadline': t.deadline.isoformat(), + 'final_status': t.status, + 'output_link': t.output_link, + } + for t in tasks + ] + + def create(self, validated_data): + user_id = self.context.get('user_id') + today = now().date() + + target_date = validated_data.pop('week_start_date', None) or today + iso_year, iso_week, weekday = target_date.isocalendar() + week_start_date = target_date - timedelta(days=weekday - 1) + week_end_date = week_start_date + timedelta(days=6) + + rating = validated_data.pop('rating', None) + next_week_plan = validated_data.pop('next_week_plan', None) + challenges_faced = validated_data.pop('challenges_faced', None) + learnings = validated_data.pop('learnings', None) + + if 'task_remarks' not in validated_data: + validated_data['task_remarks'] = { + 'rating': rating, + 'next_week_plan': next_week_plan, + 'challenges_faced': challenges_faced, + 'learnings': learnings + } + + if 'weekly_review' not in validated_data or not validated_data.get('weekly_review'): + parts = [] + if learnings: + parts.append(f"Learnings: {learnings}") + if challenges_faced: + parts.append(f"Challenges Faced: {challenges_faced}") + if next_week_plan: + parts.append(f"Next Week Plan: {next_week_plan}") + validated_data['weekly_review'] = "\n\n".join(parts) or "Weekly review submission" + + # Auto-populate tasks_completed as structured JSON + validated_data['tasks_completed'] = self._build_tasks_completed(user_id, iso_week) + validated_data['tasks_assigned'] = validated_data.get('tasks_assigned') or {} + + # Auto-populate team from UserInternGuildLink if not provided + if not validated_data.get('team'): + guild_link = UserInternGuildLink.objects.filter(user_id=user_id).first() + validated_data['team'] = guild_link.guild if guild_link else '' + + validated_data['user_id'] = user_id + validated_data['iso_year'] = iso_year + validated_data['iso_week'] = iso_week + validated_data['week_start_date'] = week_start_date + validated_data['week_end_date'] = week_end_date + # Mark late if submitted after the week ended (past Sunday) + validated_data['is_late'] = now().date() > week_end_date + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + validated_data['status'] = InternSubmissionStatus.PENDING.value + + return super().create(validated_data) + + def update(self, instance, validated_data): + user_id = self.context.get('user_id') + if user_id: + validated_data['updated_by_id'] = user_id + return super().update(instance, validated_data) + + +class InternWeeklyReviewHistorySerializer(serializers.ModelSerializer): + class Meta: + model = InternWeeklyReview + fields = [ + 'id', 'iso_year', 'iso_week', 'week_start_date', 'week_end_date', + 'team', 'is_on_leave', 'tasks_assigned', 'tasks_completed', + 'weekly_review', 'task_remarks', 'hours_committed', 'blockers', + 'leave_days', 'suggestions', 'is_late', 'status', + 'review_note', 'created_at' + ] diff --git a/api/dashboard/intern/weekly_review/urls.py b/api/dashboard/intern/weekly_review/urls.py new file mode 100644 index 000000000..de08c163f --- /dev/null +++ b/api/dashboard/intern/weekly_review/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import weekly_review_views + +urlpatterns = [ + path("", weekly_review_views.InternWeeklyReviewAPI.as_view(), name="intern-weekly-review-list-create"), + path("prefill/", weekly_review_views.InternWeeklyReviewPrefillAPI.as_view(), name="intern-weekly-review-prefill"), + path("current/", weekly_review_views.InternWeeklyReviewCurrentAPI.as_view(), name="intern-weekly-review-current"), + path("history/", weekly_review_views.InternWeeklyReviewHistoryAPI.as_view(), name="intern-weekly-review-history"), + path("/", weekly_review_views.InternWeeklyReviewAPI.as_view(), name="intern-weekly-review-edit"), +] diff --git a/api/dashboard/intern/weekly_review/weekly_review_views.py b/api/dashboard/intern/weekly_review/weekly_review_views.py new file mode 100644 index 000000000..af75ae6bc --- /dev/null +++ b/api/dashboard/intern/weekly_review/weekly_review_views.py @@ -0,0 +1,202 @@ +from db.intern import InternTask +from django.db import IntegrityError +from django.utils.timezone import now +from datetime import timedelta + +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternSubmissionStatus, InternGuildStatus +from utils.utils import CommonUtils +from db.intern import InternWeeklyReview, UserInternGuildLink + +from .serializers import InternWeeklyReviewSerializer, InternWeeklyReviewHistorySerializer + + +class InternWeeklyReviewPrefillAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description=( + "Retrieve a snapshot of this week's tasks (COMPLETED / WAITING_FOR_REVIEW) " + "to pre-populate the weekly review form before submission." + ), + responses={200: OpenApiResponse(description="Current week task snapshot.")}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + today = now().date() + iso_year, iso_week, weekday = today.isocalendar() + week_start = today - timedelta(days=weekday - 1) + week_end = week_start + timedelta(days=6) + + tasks = InternTask.objects.filter( + assigned_to_id=user_id, + iso_week=iso_week, + is_archived=False, + ).order_by('deadline') + + task_list = [ + { + "task_id": str(t.id), + "title": t.title, + "category": t.category, + "complexity": t.complexity, + "deadline": t.deadline.isoformat(), + "status": t.status, + "output_link": t.output_link, + } + for t in tasks + ] + + return CustomResponse(response={ + "iso_year": iso_year, + "iso_week": iso_week, + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + "tasks": task_list, + }).get_success_response() + + +class InternWeeklyReviewAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve weekly review(s). Pass a review_id to get a specific review.", + responses={200: InternWeeklyReviewHistorySerializer}, + ) + def get(self, request, review_id=None): + user_id = JWTUtils.fetch_user_id(request) + if review_id: + review = InternWeeklyReview.objects.filter(id=review_id, user_id=user_id).first() + if not review: + return CustomResponse(general_message="Weekly review not found.").get_failure_response() + serializer = InternWeeklyReviewHistorySerializer(review) + return CustomResponse(response=serializer.data).get_success_response() + + reviews = InternWeeklyReview.objects.filter(user_id=user_id).order_by('-iso_year', '-iso_week') + + paginated_queryset = CommonUtils.get_paginated_queryset( + reviews, request, + ['iso_year', 'iso_week', 'status'], + {'iso_year': 'iso_year', 'iso_week': 'iso_week', 'status': 'status'} + ) + + serializer = InternWeeklyReviewHistorySerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Submit a new weekly review.", + request=InternWeeklyReviewSerializer, + responses={200: OpenApiResponse(description="Weekly review submitted successfully.")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + guild_link = UserInternGuildLink.objects.filter(user_id=user_id).first() + + if not guild_link or guild_link.status == InternGuildStatus.INACTIVE.value: + return CustomResponse(general_message="Not an active intern.").get_failure_response() + + serializer = InternWeeklyReviewSerializer(data=request.data, context={'user_id': user_id}) + if serializer.is_valid(): + try: + serializer.save() + return CustomResponse(general_message="Weekly review submitted successfully.").get_success_response() + except IntegrityError: + return CustomResponse(general_message="Review for this week already exists.", status_code=409).get_failure_response() + return CustomResponse(response=serializer.errors).get_failure_response() + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Edit a pending weekly review for the current week.", + request=InternWeeklyReviewSerializer, + responses={200: OpenApiResponse(description="Weekly review updated successfully.")}, + ) + def patch(self, request, review_id): + user_id = JWTUtils.fetch_user_id(request) + + review = InternWeeklyReview.objects.filter(id=review_id, user_id=user_id, status=InternSubmissionStatus.PENDING.value).first() + if not review: + return CustomResponse(general_message="Pending weekly review not found.").get_failure_response() + + today = now().date() + iso_year, iso_week, _ = today.isocalendar() + if review.iso_year != iso_year or review.iso_week != iso_week: + return CustomResponse(general_message="Cannot edit reviews for past weeks.").get_failure_response() + + serializer = InternWeeklyReviewSerializer(review, data=request.data, partial=True, context={'user_id': user_id}) + if serializer.is_valid(): + serializer.save() + return CustomResponse(general_message="Weekly review updated successfully.").get_success_response() + return CustomResponse(response=serializer.errors).get_failure_response() + +class InternWeeklyReviewCurrentAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve the current week's submitted weekly review.", + responses={200: InternWeeklyReviewHistorySerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + today = now().date() + iso_year, iso_week, _ = today.isocalendar() + + review = InternWeeklyReview.objects.filter(user_id=user_id, iso_year=iso_year, iso_week=iso_week).first() + if not review: + return CustomResponse(general_message="No review submitted for the current week.").get_failure_response() + + serializer = InternWeeklyReviewHistorySerializer(review) + return CustomResponse(response=serializer.data).get_success_response() + +class InternWeeklyReviewHistoryAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.INTERN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve paginated intern weekly review history.", + responses={200: InternWeeklyReviewHistorySerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + reviews = InternWeeklyReview.objects.filter(user_id=user_id).order_by('-iso_year', '-iso_week') + + paginated_queryset = CommonUtils.get_paginated_queryset( + reviews, request, + ['iso_year', 'iso_week', 'status'], + {'iso_year': 'iso_year', 'iso_week': 'iso_week', 'status': 'status'} + ) + + paged_reviews = paginated_queryset.get("queryset") + + serializer = InternWeeklyReviewHistorySerializer(paged_reviews, many=True) + data = serializer.data + + for i, review in enumerate(paged_reviews): + data[i]['score'] = 50 if review.status == 'APPROVED' else 0 + + return CustomResponse( + response={ + "data": data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + diff --git a/api/dashboard/karma_voucher/karma_voucher_serializer.py b/api/dashboard/karma_voucher/karma_voucher_serializer.py index 0d907c904..a11b9a104 100644 --- a/api/dashboard/karma_voucher/karma_voucher_serializer.py +++ b/api/dashboard/karma_voucher/karma_voucher_serializer.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime from django.db.models import Q from rest_framework import serializers from db.task import VoucherLog, TaskList @@ -8,6 +9,14 @@ from utils.karma_voucher import generate_ordered_id +ALLOWED_MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' +] + +ALLOWED_WEEKS = ['W1', 'W2', 'W3', 'W4', 'W5'] + + class VoucherLogCSVSerializer(serializers.ModelSerializer): user_id = serializers.CharField(required=True, allow_null=False) task_id = serializers.CharField(required=True, allow_null=False) @@ -38,9 +47,29 @@ def validate(self, data): response_data = {} response_data["code"] = data.get('code') week = data.get('week') - if week and len(str(week)) > 2: - response_data["error"] = "Week must not exceed 2 characters in length and should be of the format 'W1'" + if week and week not in ALLOWED_WEEKS: + response_data["error"] = f"Week must be one of {ALLOWED_WEEKS}" raise serializers.ValidationError(response_data) + + month = data.get('month') + if month and month not in ALLOWED_MONTHS: + response_data["error"] = f"Month must be one of {ALLOWED_MONTHS}" + raise serializers.ValidationError(response_data) + + user_id = data.get('user_id') + task_id = data.get('task_id') + current_year = DateTimeUtils.get_current_utc_time().year + + if VoucherLog.objects.filter( + user_id=user_id, + task_id=task_id, + month=month, + week=week, + created_at__year=current_year + ).exists(): + response_data["error"] = "Voucher already exists for this user, task, month, and week." + raise serializers.ValidationError(response_data) + return data def to_representation(self, instance): @@ -151,10 +180,35 @@ def validate_karma(self, value): return value def validate_week(self, value): - if len(value) != 2: + if value not in ALLOWED_WEEKS: raise serializers.ValidationError( - "Week must have exactly two characters.") + f"Week must be one of {ALLOWED_WEEKS}") return value + + def validate_month(self, value): + if value not in ALLOWED_MONTHS: + raise serializers.ValidationError( + f"Month must be one of {ALLOWED_MONTHS}") + return value + + def validate(self, data): + user_id = data.get('user') + task_id = data.get('task') + month = data.get('month') + week = data.get('week') + current_year = DateTimeUtils.get_current_utc_time().year + + if VoucherLog.objects.filter( + user_id=user_id, + task_id=task_id, + month=month, + week=week, + created_at__year=current_year + ).exists(): + raise serializers.ValidationError( + "Voucher already exists for this user, task, month, and week." + ) + return data class VoucherLogUpdateSerializer(serializers.ModelSerializer): @@ -175,8 +229,8 @@ class Meta: ] def update(self, instance, validated_data): - instance.user_id = validated_data.get('new_user', instance.user) - instance.task_id = validated_data.get('new_task', instance.task) + instance.user_id = validated_data.get('new_user', instance.user_id) + instance.task_id = validated_data.get('new_task', instance.task_id) instance.karma = validated_data.get('new_karma', instance.karma) instance.month = validated_data.get('new_month', instance.month) instance.week = validated_data.get('new_week', instance.week) @@ -191,6 +245,48 @@ def validate_new_user(self, value): if not user: raise serializers.ValidationError("Enter a valid user") return user.id + + def validate_new_task(self, value): + if not TaskList.objects.filter(id=value).exists(): + raise serializers.ValidationError("Enter a valid task") + return value + + def validate_new_karma(self, value): + if value <= 0: + raise serializers.ValidationError("Enter a valid karma") + return value + + def validate_new_week(self, value): + if value not in ALLOWED_WEEKS: + raise serializers.ValidationError( + f"Week must be one of {ALLOWED_WEEKS}") + return value + + def validate_new_month(self, value): + if value not in ALLOWED_MONTHS: + raise serializers.ValidationError( + f"Month must be one of {ALLOWED_MONTHS}") + return value + + def validate(self, data): + # Use existing instance values if new ones are not provided + user_id = data.get('new_user', self.instance.user_id) + task_id = data.get('new_task', self.instance.task_id) + month = data.get('new_month', self.instance.month) + week = data.get('new_week', self.instance.week) + current_year = DateTimeUtils.get_current_utc_time().year + + if VoucherLog.objects.filter( + user_id=user_id, + task_id=task_id, + month=month, + week=week, + created_at__year=current_year + ).exclude(id=self.instance.id).exists(): + raise serializers.ValidationError( + "Voucher already exists for this user, task, month, and week." + ) + return data def destroy(self, obj): obj.delete() diff --git a/api/dashboard/karma_voucher/karma_voucher_view.py b/api/dashboard/karma_voucher/karma_voucher_view.py index 41efd5410..875aa8ecd 100644 --- a/api/dashboard/karma_voucher/karma_voucher_view.py +++ b/api/dashboard/karma_voucher/karma_voucher_view.py @@ -21,13 +21,20 @@ from utils.utils import DateTimeUtils from utils.utils import ImportCSV, CommonUtils from .karma_voucher_serializer import VoucherLogCSVSerializer, VoucherLogSerializer, VoucherLogCreateSerializer, \ - VoucherLogUpdateSerializer + VoucherLogUpdateSerializer, ALLOWED_MONTHS, ALLOWED_WEEKS +from drf_spectacular.utils import extend_schema, OpenApiResponse class ImportVoucherLogAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value]) + @extend_schema( + tags=['Dashboard - Karma Voucher'], + description="Create Import Voucher Log.", + request=VoucherLogCSVSerializer, + responses={200: VoucherLogCSVSerializer}, + ) def post(self, request): try: file_obj = request.FILES['voucher_log'] @@ -96,6 +103,12 @@ def post(self, request): elif month is None: row['error'] = "Month cannot be empty" error_rows.append(row) + elif month not in ALLOWED_MONTHS: + row['error'] = f"Month must be one of {ALLOWED_MONTHS}" + error_rows.append(row) + elif week and week not in ALLOWED_WEEKS: + row['error'] = f"Week must be one of {ALLOWED_WEEKS}" + error_rows.append(row) else: existing_codes = set( VoucherLog.objects.values_list('code', flat=True)) @@ -225,6 +238,11 @@ class VoucherLogAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value]) + @extend_schema( + tags=['Dashboard - Karma Voucher'], + description="Retrieve Voucher Log.", + responses={200: VoucherLogSerializer}, + ) def get(self, request): voucher_queryset = VoucherLog.objects.all() paginated_queryset = CommonUtils.get_paginated_queryset( @@ -255,6 +273,12 @@ def get(self, request): pagination=paginated_queryset.get('pagination')) @role_required([RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value]) + @extend_schema( + tags=['Dashboard - Karma Voucher'], + description="Create Voucher Log.", + request=VoucherLogCreateSerializer, + responses={200: VoucherLogSerializer}, + ) def post(self, request): serializer = VoucherLogCreateSerializer( data=request.data, context={'request': request}) @@ -323,6 +347,11 @@ def post(self, request): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value]) + @extend_schema( + tags=['Dashboard - Karma Voucher'], + description="Partially update Voucher Log.", + responses={200: VoucherLogUpdateSerializer}, + ) def patch(self, request, voucher_id): user_id = JWTUtils.fetch_user_id(request) context = {'user_id': user_id} @@ -338,14 +367,21 @@ def patch(self, request, voucher_id): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value]) + @extend_schema(tags=['Dashboard - Karma Voucher'], description="Delete Voucher Log.", + responses={200: VoucherLogSerializer}, + ) def delete(self, request, voucher_id): if voucher_log := VoucherLog.objects.filter(id=voucher_id).first(): + if voucher_log.claimed: + return CustomResponse( + general_message='Cannot delete a voucher that has already been claimed.' + ).get_failure_response() voucher_log.delete() return CustomResponse( - general_message=f'Voucher successfully deleted' + general_message='Voucher successfully deleted' ).get_success_response() return CustomResponse( - general_message=f'Invalid Voucher' + general_message='Invalid Voucher' ).get_failure_response() @@ -353,6 +389,11 @@ class ExportVoucherLogAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value]) + @extend_schema( + tags=['Dashboard - Karma Voucher'], + description="Retrieve Export Voucher Log.", + responses={200: VoucherLogSerializer}, + ) def get(self, request): voucher_serializer = VoucherLog.objects.all() voucher_serializer_data = VoucherLogSerializer( @@ -364,6 +405,9 @@ def get(self, request): class VoucherBaseTemplateAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Karma Voucher'], description="Retrieve Voucher Base Template.", + responses={200: OpenApiResponse(description="XLSX file download")}, + ) def get(self, request): wb = load_workbook('./excel-templates/voucher_base_template.xlsx') ws = wb['Data Definitions'] diff --git a/api/dashboard/lc/dash_lc_serializer.py b/api/dashboard/lc/dash_lc_serializer.py index 32b6471e7..ecd94c0c3 100644 --- a/api/dashboard/lc/dash_lc_serializer.py +++ b/api/dashboard/lc/dash_lc_serializer.py @@ -1,5 +1,6 @@ import uuid -from datetime import datetime +from datetime import datetime, timedelta, timezone + from django.conf import settings from decouple import config from django.db.models import Sum @@ -24,6 +25,43 @@ from .dash_ig_helper import get_today_start_end, get_week_start_end +def _aware_meet_time(meet_time): + if meet_time is None: + return None + if meet_time.tzinfo is None: + return meet_time.replace(tzinfo=timezone.utc) + return meet_time + + +def meeting_is_started(meet_time): + aware_time = _aware_meet_time(meet_time) + if aware_time is None: + return False + return aware_time <= DateTimeUtils.get_current_utc_time() + + +def meeting_is_ended(meet_time, duration_hours): + aware_time = _aware_meet_time(meet_time) + if aware_time is None: + return False + duration = duration_hours if duration_hours is not None else 0 + return ( + aware_time + timedelta(hours=duration) + ) <= DateTimeUtils.get_current_utc_time() + + +class CircleMeetTimeFieldsMixin: + is_started = serializers.SerializerMethodField() + is_ended = serializers.SerializerMethodField() + + def get_is_started(self, obj): + return meeting_is_started(obj.meet_time) + + def get_is_ended(self, obj): + duration = getattr(obj, "duration", None) + return meeting_is_ended(obj.meet_time, duration) + + class LearningCircleSerializer(serializers.ModelSerializer): created_by = serializers.CharField(source="created_by.full_name") updated_by = serializers.CharField(source="updated_by.full_name") @@ -651,8 +689,7 @@ def create(self, validated_data): return UserCircleLink.objects.create(**validated_data) -class CircleMeetDetailSerializer(serializers.ModelSerializer): - is_started = serializers.BooleanField(read_only=True) +class CircleMeetDetailSerializer(CircleMeetTimeFieldsMixin, serializers.ModelSerializer): id = serializers.CharField(read_only=True) title = serializers.CharField(required=True) location = serializers.CharField(required=True) @@ -752,6 +789,7 @@ class Meta: "pre_requirements", "is_public", "is_started", + "is_ended", "max_attendees", "report_text", "meet_code", @@ -847,8 +885,7 @@ class Meta: ] -class CircleMeetSerializer(serializers.ModelSerializer): - is_started = serializers.BooleanField(read_only=True) +class CircleMeetSerializer(CircleMeetTimeFieldsMixin, serializers.ModelSerializer): id = serializers.CharField(read_only=True) title = serializers.CharField(required=True) location = serializers.CharField(required=True) @@ -900,6 +937,7 @@ class Meta: "pre_requirements", "is_public", "is_started", + "is_ended", "max_attendees", "report_text", "meet_code", @@ -909,7 +947,7 @@ class Meta: ] -class CircleMeetBasicDetails(serializers.ModelSerializer): +class CircleMeetBasicDetails(CircleMeetTimeFieldsMixin, serializers.ModelSerializer): id = serializers.CharField(read_only=True) title = serializers.CharField(required=True) location = serializers.CharField(read_only=True) @@ -987,6 +1025,7 @@ class Meta: "pre_requirements", "is_public", "is_started", + "is_ended", "max_attendees", "report_text", "meet_code", diff --git a/api/dashboard/lc/dash_lc_view.py b/api/dashboard/lc/dash_lc_view.py index e82be4348..9a263afa6 100644 --- a/api/dashboard/lc/dash_lc_view.py +++ b/api/dashboard/lc/dash_lc_view.py @@ -7,6 +7,7 @@ from django.shortcuts import redirect from rest_framework.views import APIView from api.notification.notifications_utils import NotificationUtils +from api.notification.broadcast_utils import BroadcastUtils from db.learning_circle import ( CircleMeetAttendeeReport, CircleMeetAttendees, @@ -48,6 +49,9 @@ CircleMeetSerializer, ) from decouple import config +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s +from utils.schema_utils import CustomResponseSerializer BE_DOMAIN = config("BE_DOMAIN_NAME") @@ -63,6 +67,29 @@ class UserLearningCircleListApi(APIView): associated with the user. """ + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve User Learning Circle List Api.", + responses={200: inline_serializer("LcUserCircleListResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(), + "response": inline_serializer("LcUserCircleItem", many=True, fields={ + "id": s.CharField(), + "name": s.CharField(), + "circle_code": s.CharField(), + "ig": s.CharField(help_text="Interest group name"), + "org": s.CharField(allow_null=True, help_text="Organisation title"), + "meet_place": s.CharField(allow_null=True), + "meet_time": s.CharField(allow_null=True), + "updated_by": s.CharField(), + "updated_at": s.DateTimeField(), + "created_by": s.CharField(), + "created_at": s.DateTimeField(), + "member_count": s.IntegerField(), + }), + })}, + ) def get(self, request): # Lists user's learning circle user_id = JWTUtils.fetch_user_id(request) @@ -77,6 +104,28 @@ def get(self, request): # Lists user's learning circle class LearningCircleMainApi(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Learning Circle Main Api.", + responses={200: inline_serializer("LcMainListResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(), + "response": inline_serializer("LcMainItem", many=True, fields={ + "id": s.CharField(), + "name": s.CharField(), + "ig_name": s.CharField(), + "org_name": s.CharField(allow_null=True), + "member_count": s.IntegerField(), + "members": s.ListField(child=s.DictField(), allow_null=True), + "meet_place": s.CharField(allow_null=True), + "meet_time": s.CharField(allow_null=True), + "lead_name": s.CharField(allow_null=True), + "ismember": s.BooleanField(), + "karma": s.IntegerField(), + }), + })}, + ) def post(self, request): all_circles = LearningCircle.objects.all() if JWTUtils.is_logged_in(request): @@ -97,8 +146,8 @@ def post(self, request): serializer = LearningCircleMainSerializer(all_circles, many=True) else: random_circles = all_circles.exclude( - Q(meet_time__isnull=True) | Q(meet_time="") - and Q(meet_place__isnull=True) | Q(meet_place="") + (Q(meet_time__isnull=True) | Q(meet_time="")) + & (Q(meet_place__isnull=True) | Q(meet_place="")) ).order_by("?")[:9] # random_circles = all_circles.order_by('?')[:9] @@ -110,9 +159,8 @@ def post(self, request): return CustomResponse(response=sorted_data).get_success_response() else: random_circles = all_circles.exclude( - Q(meet_time__isnull=True) - | Q(meet_time="") & Q(meet_place__isnull=True) - | Q(meet_place="") + (Q(meet_time__isnull=True) | Q(meet_time="")) + & (Q(meet_place__isnull=True) | Q(meet_place="")) ).order_by("?")[:9] serializer = LearningCircleMainSerializer(random_circles, many=True) @@ -134,6 +182,11 @@ class LearningCircleStatsAPI(APIView): CustomResponse: A custom response containing data about all learning circles. """ + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve Learning Circle Stats.", + responses={200: LearningCircleStatsSerializer}, + ) def get(self, request): learning_circle = LearningCircle.objects.all() @@ -143,6 +196,19 @@ def get(self, request): class LearningCircleCreateApi(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Learning Circle Create Api.", + request=LearningCircleCreateSerializer, + responses={200: inline_serializer("LcCreateResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(), + "response": inline_serializer("LcCreatePayload", fields={ + "circle_id": s.CharField(), + }), + })}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -152,6 +218,19 @@ def post(self, request): if serializer.is_valid(): circle = serializer.save() + # Broadcast new LC creation to all campus members (if the circle belongs to a campus) + creator = User.objects.filter(id=user_id).first() + if creator and circle.org_id: + BroadcastUtils.create_broadcast( + title='New Learning Circle Created', + description=f'A new Learning Circle "{circle.title}" has been formed in your campus!', + target_type='campus', + target_id=circle.org_id, + created_by=creator, + expiry_key='lc_created', + url=f'/dashboard/learning-circle/', + ) + return CustomResponse( general_message="LearningCircle created successfully", response={"circle_id": circle.id}, @@ -161,13 +240,28 @@ def post(self, request): class LearningCircleListMembersApi(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve Learning Circle List Members Api.", + responses={200: inline_serializer("LcMemberListResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(), + "response": inline_serializer("LcMemberItem", many=True, fields={ + "full_name": s.CharField(), + "discord_id": s.CharField(allow_null=True), + "level": s.CharField(allow_null=True), + "lc_karma": s.IntegerField(), + }), + })}, + ) def get(self, request, circle_id): # learning_circle = LearningCircle.objects.filter( # id=circle_id # ) user_learning_circle = UserCircleLink.objects.filter(circle_id=circle_id) - if user_learning_circle is None: + if not user_learning_circle.exists(): return CustomResponse( general_message="Learning Circle Not Exists" ).get_failure_response() @@ -180,6 +274,29 @@ def get(self, request, circle_id): class TotalLearningCircleListApi(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Total Learning Circle List Api.", + responses={200: inline_serializer("LcTotalListResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(), + "response": inline_serializer("LcTotalListItem", many=True, fields={ + "id": s.CharField(), + "name": s.CharField(), + "circle_code": s.CharField(), + "ig": s.CharField(help_text="Interest group name"), + "org": s.CharField(allow_null=True, help_text="Organisation title"), + "meet_place": s.CharField(allow_null=True), + "meet_time": s.CharField(allow_null=True), + "updated_by": s.CharField(), + "updated_at": s.DateTimeField(), + "created_by": s.CharField(), + "created_at": s.DateTimeField(), + "member_count": s.IntegerField(), + }), + })}, + ) def post(self, request, circle_code=None): user_id = JWTUtils.fetch_user_id(request) filters = Q() @@ -214,6 +331,12 @@ def post(self, request, circle_code=None): class LearningCircleJoinApi(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Learning Circle Join Api.", + request=LearningCircleJoinSerializer, + responses={200: LearningCircleJoinSerializer}, + ) def post(self, request, circle_id): user_id = JWTUtils.fetch_user_id(request) @@ -225,15 +348,18 @@ def post(self, request, circle_id): ) if serializer.is_valid(): serializer.save() - lead = UserCircleLink.objects.filter(circle_id=circle_id, lead=True).first() - NotificationUtils.insert_notification( - user=lead.user, - title="Member Request", - description=f"{full_name} has requested to join your learning circle", - button="LC", - url=f"{settings.FR_DOMAIN_NAME}/api/v1/dashboard/lc/{circle_id}/{user_id}/", - created_by=user, - ) + lead = UserCircleLink.objects.filter( + circle_id=circle_id, lead=True + ).first() + if lead: + NotificationUtils.insert_notification( + user=lead.user, + title="Member Request", + description=f"{full_name} has requested to join your learning circle", + button="View", + url=f"{settings.FR_DOMAIN_NAME}/api/v1/dashboard/lc/{circle_id}/{user_id}/", + created_by=user, + ) return CustomResponse(general_message="Request sent").get_success_response() @@ -241,6 +367,34 @@ def post(self, request, circle_id): class LearningCircleDetailsApi(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve Learning Circle Details Api.", + responses={200: inline_serializer("LcDetailsResponse", fields={ + "hasError": s.BooleanField(default=False), + "statusCode": s.IntegerField(default=200), + "message": s.DictField(), + "response": inline_serializer("LcDetailsPayload", fields={ + "name": s.CharField(), + "circle_code": s.CharField(), + "note": s.CharField(allow_null=True), + "meet_time": s.CharField(allow_null=True), + "meet_place": s.CharField(allow_null=True), + "day": s.CharField(allow_null=True), + "college": s.CharField(allow_null=True), + "members": s.ListField(child=s.DictField()), + "pending_members": s.ListField(child=s.DictField()), + "rank": s.IntegerField(allow_null=True), + "total_karma": s.IntegerField(), + "is_lead": s.BooleanField(), + "is_member": s.BooleanField(), + "ig_id": s.CharField(), + "ig_name": s.CharField(), + "ig_code": s.CharField(), + "previous_meetings": s.ListField(child=s.DictField()), + }), + })}, + ) def get(self, request, circle_id, member_id=None): user_id = JWTUtils.fetch_user_id(request) @@ -264,7 +418,23 @@ def get(self, request, circle_id, member_id=None): return CustomResponse(response=serializer.data).get_success_response() + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Learning Circle Details Api.", + responses={200: LearningCircleUpdateSerializer}, + ) def post(self, request, member_id, circle_id): + user_id = JWTUtils.fetch_user_id(request) + circle = LearningCircle.objects.filter(id=circle_id).first() + is_creator = circle and circle.created_by_id == user_id + is_lead = UserCircleLink.objects.filter( + circle_id=circle_id, user_id=user_id, lead=True, accepted=True + ).exists() + if not is_creator and not is_lead: + return CustomResponse( + general_message="Only circle leads can remove members" + ).get_failure_response() + learning_circle_link = UserCircleLink.objects.filter( user_id=member_id, circle_id=circle_id ).first() @@ -281,6 +451,11 @@ def post(self, request, member_id, circle_id): general_message="Removed successfully" ).get_success_response() + @extend_schema( + tags=['Dashboard - Lc'], + description="Partially update Learning Circle Details Api.", + responses={200: LearningCircleUpdateSerializer}, + ) def patch(self, request, member_id, circle_id): user_id = JWTUtils.fetch_user_id(request) @@ -317,7 +492,7 @@ def patch(self, request, member_id, circle_id): user, title="Request approved", description="You request to join the learning circle has been approved", - button="LC", + button="View", url=f"{settings.FR_DOMAIN_NAME}/api/v1/dashboard/lc/{circle_id}/", created_by=User.objects.filter(id=user_id).first(), ) @@ -326,8 +501,8 @@ def patch(self, request, member_id, circle_id): user, title="Request rejected", description="You request to join the learning circle has been rejected", - button="LC", - url=f"{settings.FR_DOMAIN_NAME}/api/v1/dashboard/lc/join", + button=None, + url=None, created_by=User.objects.filter(id=user_id).first(), ) @@ -337,6 +512,11 @@ def patch(self, request, member_id, circle_id): return CustomResponse(message=serializer.errors).get_failure_response() + @extend_schema( + tags=['Dashboard - Lc'], + description="Update Learning Circle Details Api.", + responses={200: LearningCircleNoteSerializer}, + ) def put(self, request, circle_id): learning_circle = LearningCircle.objects.filter(id=circle_id).first() serializer = LearningCircleNoteSerializer(learning_circle, data=request.data) @@ -348,6 +528,9 @@ def put(self, request, circle_id): return CustomResponse(message=serializer.errors).get_failure_response() + @extend_schema(tags=['Dashboard - Lc'], description="Delete Learning Circle Details Api.", + responses={200: OpenApiResponse(description="Success — returns message: Leadership transferred | Learning Circle Deleted | Left")}, + ) def delete(self, request, circle_id): user_id = JWTUtils.fetch_user_id(request) usr_circle_link = UserCircleLink.objects.filter( @@ -441,6 +624,9 @@ def delete(self, request, circle_id): class LearningCircleLeadTransfer(APIView): + @extend_schema(tags=['Dashboard - Lc'], description="Partially update Learning Circle Lead Transfer.", + responses={200: OpenApiResponse(description="Success — message: Lead transferred successfully")}, + ) def patch(self, request, circle_id, new_lead_id): user_id = JWTUtils.fetch_user_id(request) @@ -478,6 +664,9 @@ def patch(self, request, circle_id, new_lead_id): class LearningCircleInviteLeadAPI(APIView): + @extend_schema(tags=['Dashboard - Lc'], description="Create Learning Circle Invite Lead.", + responses={200: OpenApiResponse(description="Success — message: User Invited")}, + ) def post(self, request): circle_id = request.POST.get("lc") muid = request.POST.get("muid") @@ -509,12 +698,19 @@ def post(self, request): ) return CustomResponse(general_message="User Invited").get_success_response() + return CustomResponse( + general_message="Only circle leads can invite users" + ).get_failure_response() + class LearningCircleInviteMemberAPI(APIView): """ Invite a member to a learning circle. """ + @extend_schema(tags=['Dashboard - Lc'], description="Create Learning Circle Invite Member.", + responses={200: OpenApiResponse(description="Success — message: User Invited")}, + ) def post(self, request, circle_id, muid): """ POST request to invite a member to a learning circle. @@ -566,7 +762,7 @@ def post(self, request, circle_id, muid): circle_id=circle_id, user=user, is_invited=True, - accepted=False, + accepted=None, created_at=DateTimeUtils.get_current_utc_time(), ) @@ -580,6 +776,9 @@ class LearningCircleInvitationStatus(APIView): API to update the invitation status """ + @extend_schema(tags=['Dashboard - Lc'], description="Create Learning Circle Invitation Status.", + responses={200: OpenApiResponse(description="Success — redirects to FR_DOMAIN/dashboard/learning-circle/ on acceptance, or message: User rejected invitation on rejection")}, + ) def post(self, request, circle_id, muid, status): """ PUT request to accept the invitation to join the learning circle, by adding the user data to the lc @@ -607,7 +806,7 @@ def post(self, request, circle_id, muid, status): user_circle_link.accepted = True user_circle_link.accepted_at = DateTimeUtils.get_current_utc_time() user_circle_link.save() - # return CustomResponse(general_message='User added to circle').get_success_response() + return redirect(f"{settings.FR_DOMAIN_NAME}/dashboard/learning-circle/") elif status == "rejected": @@ -619,6 +818,11 @@ def post(self, request, circle_id, muid, status): class ScheduleMeetAPI(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Update Schedule Meet.", + responses={200: ScheduleMeetingSerializer}, + ) def put(self, request, circle_id): learning_circle = LearningCircle.objects.filter(id=circle_id).first() @@ -634,6 +838,11 @@ def put(self, request, circle_id): class IgTaskDetailsAPI(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve Ig Task Details.", + responses={200: IgTaskDetailsSerializer}, + ) def get(self, request, circle_id): task_list = TaskList.objects.filter( ig__learning_circle_ig__id=circle_id @@ -653,6 +862,12 @@ def get(self, request, circle_id): class AddMemberAPI(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Add Member.", + request=AddMemberSerializer, + responses={200: AddMemberSerializer}, + ) def post(self, request, circle_id): muid = request.data.get("muid") @@ -679,6 +894,9 @@ def post(self, request, circle_id): class ValidateUserMeetCreateAPI(APIView): + @extend_schema(tags=['Dashboard - Lc'], description="Retrieve Validate User Meet Create.", + responses={200: OpenApiResponse(description="Success — message: success (circle is eligible for a new meet today)")}, + ) def get(self, request, circle_id): user_id = JWTUtils.fetch_user_id(request) @@ -724,6 +942,12 @@ class CircleMeetAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Circle Meet.", + request=CircleMeetSerializer, + responses={200: CircleMeetTasksSerializer}, + ) def post(self, request, circle_id): user_id = JWTUtils.fetch_user_id(request) user = User.objects.filter(id=user_id).first() @@ -750,22 +974,26 @@ def post(self, request, circle_id): return CustomResponse(message=serializer.errors).get_failure_response() + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve Circle Meet.", + responses={200: CircleMeetSerializer}, + ) def get(self, request, circle_id): user_id = JWTUtils.fetch_user_id(request) + now = DateTimeUtils.get_current_utc_time() up_coming_meeting = CircleMeetingLog.objects.filter( - meet_time__gte=DateTimeUtils.get_current_utc_time(), + meet_time__gt=now, circle_id=circle_id, is_report_submitted=False, - is_started=False, ).order_by("-created_at") report_pending = CircleMeetingLog.objects.filter( + meet_time__lte=now, circle_id=circle_id, is_report_submitted=False, - is_started=True, ).order_by("-created_at") past_meeting = CircleMeetingLog.objects.filter( circle_id=circle_id, - is_started=True, is_report_submitted=True, ).order_by("-created_at")[:2] @@ -785,6 +1013,12 @@ class CircleMeetReportSubmitAPI(APIView): Submit report for meetup """ + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Circle Meet Report Submit.", + request=MeetRecordsCreateEditDeleteSerializer, + responses={200: MeetRecordsCreateEditDeleteSerializer}, + ) def post(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) user = User.objects.filter(id=user_id).first() @@ -826,6 +1060,11 @@ class CircleMeetDetailAPI(APIView): Get details of a meetup """ + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve Circle Meet Detail.", + responses={200: CircleMeetDetailSerializer}, + ) def get(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) user = User.objects.filter(id=user_id).first() @@ -847,6 +1086,11 @@ class CircleMeetListAPI(APIView): List all meetups available """ + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve Circle Meet List.", + responses={200: CircleMeetSerializer}, + ) def get(self, request, is_user=None): if is_user: if not (user_id := JWTUtils.fetch_user_id(request)): @@ -900,6 +1144,9 @@ class CircleMeetInterestedAPI(APIView): Show interest to join a meetup """ + @extend_schema(tags=['Dashboard - Lc'], description="Create Circle Meet Interested.", + responses={200: CircleMeetSerializer}, + ) def post(self, request, meet_id): notes = request.data.get("notes") user_id = JWTUtils.fetch_user_id(request) @@ -935,6 +1182,9 @@ def post(self, request, meet_id): general_message=f"Interest shown successfully. You can join the meetup when it starts." ).get_success_response() + @extend_schema(tags=['Dashboard - Lc'], description="Delete Circle Meet Interested.", + responses={200: CircleMeetSerializer}, + ) def delete(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) user = User.objects.filter(id=user_id).first() @@ -956,6 +1206,9 @@ class CircleMeetJoinAPI(APIView): Join a meetup """ + @extend_schema(tags=['Dashboard - Lc'], description="Create Circle Meet Join.", + responses={200: CircleMeetSerializer}, + ) def post(self, request, meet_code_id): user_id = JWTUtils.fetch_user_id(request) user = User.objects.filter(id=user_id).first() @@ -969,10 +1222,12 @@ def post(self, request, meet_code_id): return CustomResponse( general_message="Invalid meeting code" ).get_failure_response() - if not meet.is_started: - meet.is_started = True - meet.save() + if not is_learning_circle_member(user_id, meet.circle_id_id): + return CustomResponse( + general_message="You must be an accepted member of the learning circle to join" + ).get_failure_response() attendee = CircleMeetAttendees.objects.filter(meet=meet, user=user).first() + award_karma = False if attendee: if attendee.joined_at: return CustomResponse( @@ -980,6 +1235,7 @@ def post(self, request, meet_code_id): ).get_failure_response() attendee.joined_at = DateTimeUtils.get_current_utc_time() attendee.save() + award_karma = True else: attendee = CircleMeetAttendees.objects.create( id=uuid.uuid4(), @@ -989,6 +1245,7 @@ def post(self, request, meet_code_id): created_at=DateTimeUtils.get_current_utc_time(), updated_at=DateTimeUtils.get_current_utc_time(), ) + award_karma = True task = TaskList.objects.filter(hashtag=Lc.MEET_JOIN_HASHTAG.value).first() KarmaActivityLog.objects.create( id=uuid.uuid4(), @@ -1016,14 +1273,41 @@ def post(self, request, meet_code_id): class CircleMeetAttendeesListAPI(APIView): + @extend_schema( + tags=['Dashboard - Lc'], + description="Retrieve Circle Meet Attendees List.", + responses={200: CircleAttendeeReportSerializer}, + ) def get(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) if meet_id: + attendee_rows = list( + CircleMeetAttendees.objects.filter( + meet_id=meet_id, joined_at__isnull=False + ) + .select_related("user") + .values_list( + "id", + "user__full_name", + "user_id", + "user__muid", + "report", + ) + .distinct() + ) + users_by_id = { + u.id: u + for u in User.objects.filter( + id__in=[row[2] for row in attendee_rows] + ) + } attendees = [ { "attendee_id": attendee[0], "fullname": attendee[1], - "profile_pic": f"{BE_DOMAIN}/{settings.MEDIA_URL}{attendee[2]}", + "profile_pic": users_by_id[attendee[2]].profile_pic + if attendee[2] in users_by_id + else None, "muid": attendee[3], "proof_of_work": CircleAttendeeReportSerializer( CircleMeetAttendeeReport.objects.filter( @@ -1033,31 +1317,20 @@ def get(self, request, meet_id): ).data, "report": attendee[4], } - for attendee in ( - CircleMeetAttendees.objects.filter( - meet_id=meet_id, joined_at__isnull=False - ) - .select_related("user") - .values_list( - "id", - "user__full_name", - "user_id", - "user__muid", - "report", - ) - .distinct() - ) + for attendee in attendee_rows ] return CustomResponse(response=attendees).get_success_response() return CustomResponse(general_message="Invalid meeting").get_failure_response() class CircleAttendeeReportAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Lc'], description="Create Circle Attendee Report.", + responses={200: CircleAttendeeReportSerializer}, + ) def post(self, request, meet_id): - if not (user_id := JWTUtils.fetch_user_id(request)): - return CustomResponse( - general_message="Unauthorized access" - ).get_failure_response() + user_id = JWTUtils.fetch_user_id(request) if not (meet := CircleMeetingLog.objects.filter(id=meet_id).first()): return CustomResponse( general_message="Invalid meeting" @@ -1096,11 +1369,16 @@ def post(self, request, meet_id): class CircleMeetTaskPOWAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Lc'], + description="Create Circle Meet Task P O W.", + request=CircleAttendeeReportSerializer, + responses={200: CircleAttendeeReportSerializer}, + ) def post(self, request, meet_id, task_id): - if not (user_id := JWTUtils.fetch_user_id(request)): - return CustomResponse( - general_message="Unauthorized access" - ).get_failure_response() + user_id = JWTUtils.fetch_user_id(request) if not (CircleMeetingLog.objects.filter(id=meet_id).exists()): return CustomResponse( general_message="Invalid meeting" @@ -1147,6 +1425,9 @@ class CircleMeetVerifyAPI(APIView): RoleType.APPRAISER.value, ] ) + @extend_schema(tags=['Dashboard - Lc'], description="Retrieve Circle Meet Verify.", + responses={200: CircleMeetSerializer}, + ) def get(self, request): unverified_meets = CircleMeetingLog.objects.select_related("circle").filter( is_report_submitted=True, is_verified=False @@ -1162,14 +1443,11 @@ def get(self, request): RoleType.APPRAISER.value, ] ) + @extend_schema(tags=['Dashboard - Lc'], description="Create Circle Meet Verify.", + responses={200: CircleMeetSerializer}, + ) def post(self, request, meet_id): - return CustomResponse( - general_message="This endpoint only supports GET requests." - ).get_failure_response() - if not (user_id := JWTUtils.fetch_user_id(request)): - return CustomResponse( - general_message="Unauthorized access" - ).get_failure_response() + user_id = JWTUtils.fetch_user_id(request) attendees_karma = request.data.get("attendees_karma") if ( not attendees_karma @@ -1215,7 +1493,7 @@ def post(self, request, meet_id): id=uuid.uuid4(), user_id=attendee.user_id, karma=karma, - task_id=task, + task=task, updated_by=user, created_by=user, appraiser_approved=True, diff --git a/api/dashboard/learningcircle/learningcircle_serializer.py b/api/dashboard/learningcircle/learningcircle_serializer.py index 44732f3d0..2ecaf2a86 100644 --- a/api/dashboard/learningcircle/learningcircle_serializer.py +++ b/api/dashboard/learningcircle/learningcircle_serializer.py @@ -1,26 +1,53 @@ +import uuid from datetime import datetime, timedelta, timezone import pytz -from db.learning_circle import LearningCircle, CircleMeetingLog, CircleMeetingAttendees + + +def _aware_meet_time(meet_time): + if meet_time is None: + return None + if meet_time.tzinfo is None: + return meet_time.replace(tzinfo=timezone.utc) + return meet_time + + +def meeting_is_started(meet_time): + aware_time = _aware_meet_time(meet_time) + if aware_time is None: + return False + return aware_time <= DateTimeUtils.get_current_utc_time() + + +def meeting_is_ended(meet_time, duration_hours): + aware_time = _aware_meet_time(meet_time) + if aware_time is None: + return False + return ( + aware_time + timedelta(hours=duration_hours or 0) + ) <= DateTimeUtils.get_current_utc_time() +from db.learning_circle import LearningCircle, CircleMeetingLog, CircleMeetingAttendees, UserCircleLink from rest_framework import serializers from db.organization import Organization -from db.task import InterestGroup from db.user import User from utils.types import LearningCircleRecurrenceType from utils.utils import DateTimeUtils -from django.db import models from db.task import ( InterestGroup, KarmaActivityLog, - Level, - TaskList, - Wallet, - UserIgLink, - UserLvlLink, + # Level, + # TaskList, + # Wallet, + # UserIgLink, + # UserLvlLink, ) +import collections +from django.db.models import Sum + + class LearningCircleCreateEditSerialzier(serializers.ModelSerializer): ig = serializers.PrimaryKeyRelatedField( queryset=InterestGroup.objects.all(), required=True @@ -31,15 +58,15 @@ class LearningCircleCreateEditSerialzier(serializers.ModelSerializer): description = serializers.CharField(required=True) def update(self, instance, validated_data): - instance.ig_id = validated_data.get("ig_id", instance.ig_id) - instance.org_id = validated_data.get("org_id", instance.org_id) - instance.is_recurring = validated_data.get( - "is_recurring", instance.is_recurring - ) - instance.recurrence_type = validated_data.get( - "recurrence_type", instance.recurrence_type - ) - instance.recurrence = validated_data.get("recurrence", instance.recurrence) + if 'ig' in validated_data: + instance.ig = validated_data['ig'] + if 'org' in validated_data: + instance.org = validated_data['org'] + if 'title' in validated_data: + instance.title = validated_data['title'] + if 'description' in validated_data: + instance.description = validated_data['description'] + instance.updated_at = DateTimeUtils.get_current_utc_time() instance.save() return instance @@ -47,32 +74,25 @@ def update(self, instance, validated_data): def create(self, validated_data): user_id = self.context.get("user_id") validated_data["created_by_id"] = user_id - return LearningCircle.objects.create(**validated_data) + circle = LearningCircle.objects.create(**validated_data) + # The creator is implicitly the lead and an accepted member. Create their + # UserCircleLink up-front so they are counted in member totals, the circle + # surfaces in their "My Circles" list, and membership checks stay + # consistent. Idempotent via get_or_create. + UserCircleLink.objects.get_or_create( + circle=circle, + user_id=user_id, + defaults={ + "id": str(uuid.uuid4()), + "lead": True, + "is_invited": False, + "accepted": True, + "accepted_at": DateTimeUtils.get_current_utc_time(), + }, + ) + return circle def validate(self, attrs): - is_recurring = attrs.get("is_recurring") - recurrence_type = attrs.get("recurrence_type") - recurrence = attrs.get("recurrence") - if not is_recurring: - attrs["recurrence_type"] = None - attrs["recurrence"] = None - else: - if not recurrence_type or not recurrence: - raise serializers.ValidationError( - "Recurrence type and recurrence are required for recurring learning circles" - ) - if recurrence_type not in LearningCircleRecurrenceType.get_all_values(): - raise serializers.ValidationError("Invalid recurrence type.") - if recurrence_type == LearningCircleRecurrenceType.WEEKLY.value: - if recurrence < 1 or recurrence > 7: - raise serializers.ValidationError( - "Recurrence should be between 1 and 7 for weekly learning circles" - ) - elif recurrence_type == LearningCircleRecurrenceType.MONTHLY.value: - if recurrence < 1 or recurrence > 28: - raise serializers.ValidationError( - "Recurrence should be between 1 and 28 for monthly learning circles" - ) return super().validate(attrs) class Meta: @@ -80,9 +100,6 @@ class Meta: fields = [ "ig", "org", - "is_recurring", - "recurrence_type", - "recurrence", "title", "description", ] @@ -101,78 +118,198 @@ class Meta: "title", "description", "org", - "is_recurring", - "recurrence_type", - "recurrence", "created_by", ] + def to_representation(self, instance): + # Override to add calculated fields that don't exist on the model + data = super().to_representation(instance) + data['rank'] = self.get_rank(instance) + data['total_karma'] = self.get_total_karma(instance) + data['total_members'] = self.get_total_members(instance) + data['next_meetup'] = self.get_next_meetup(instance) + return data + def get_created_by(self, obj): + # Using select_related on the initial queryset (if fetching multiple LearningCircles) + # would make this access more efficient by avoiding a separate query for each obj.created_by. return { + "id": obj.created_by.id, "full_name": obj.created_by.full_name, "profile_pic": obj.created_by.profile_pic, "muid": obj.created_by.muid, } - # next_meetup = serializers.SerializerMethodField() - - # def _get_next_weekday(self, target_day: int): - # today = datetime.now() - # current_day = today.isoweekday() + 2 - # current_day = current_day if current_day <= 7 else 1 - # days_until_next = ((target_day - current_day + 7) % 7) + 1 - # days_until_next = days_until_next or 7 - # next_day_date = today + timedelta(days=days_until_next) - # return next_day_date.date() - - # def _get_month_day(self, target_day: int): - # today = datetime.now() - # current_day = today.day - # current_month = today.month - # current_year = today.year - # if current_day >= target_day: - # current_month += 1 - # if current_month > 12: - # current_month = 1 - # current_year += 1 - # return datetime(current_year, current_month, target_day).date() - - # def get_next_meetup(self, obj): - # next_meetup = ( - # CircleMeetingLog.objects.filter(circle_id=obj.id) - # .filter( - # # meet_time__gte=DateTimeUtils.get_current_utc_time(), - # is_report_submitted=False, - # ) - # .order_by("-meet_time") - # .first() - # ) - # if next_meetup: - # return { - # **CircleMeetingLogListSerializer(next_meetup).data, - # "is_scheduled": True, - # } - # if not obj.is_recurring: - # return None - # if obj.recurrence_type == LearningCircleRecurrenceType.WEEKLY.value: - # return { - # "is_scheduled": False, - # "meet_time": self._get_next_weekday(obj.recurrence), - # } - # if obj.recurrence_type == LearningCircleRecurrenceType.MONTHLY.value: - # return { - # "is_scheduled": False, - # "meet_time": self._get_month_day(obj.recurrence), - # } - # return {"is_scheduled": False, "meet_time": None} + def get_total_members(self, obj): + """Calculate total number of accepted members in this circle""" + return UserCircleLink.objects.filter( + circle=obj.id, + accepted=True, + ).count() + + @property + def _all_circle_rankings_data(self): + """ + Calculates and caches the rankings for all circles. + This method will only run once per serializer instance. + """ + if not hasattr(self, '_cached_circle_rankings'): + user_circle_links = UserCircleLink.objects.filter(accepted=True).values('circle_id', 'user_id') + + circle_to_users = collections.defaultdict(list) + for link in user_circle_links: + circle_to_users[link['circle_id']].append(link['user_id']) + + circle_igs = dict(LearningCircle.objects.values_list('id', 'ig_id')) + + user_karma = ( + KarmaActivityLog.objects + .values('user_id', 'task__ig') + .annotate(total_karma=Sum('karma')) + ) + + user_ig_karma = collections.defaultdict(int) + for entry in user_karma: + user_ig_karma[(entry['user_id'], entry['task__ig'])] += entry['total_karma'] + + circle_data = [] + for circle_id, user_ids in circle_to_users.items(): + ig_id = circle_igs.get(circle_id) + total = sum(user_ig_karma.get((uid, ig_id), 0) for uid in user_ids) + circle_data.append({ + 'id': circle_id, + 'total_karma': total + }) + + all_circle_ids = set(circle_igs.keys()) + existing_ids = {c['id'] for c in circle_data} + for missing_id in all_circle_ids - existing_ids: + circle_data.append({ + 'id': missing_id, + 'total_karma': 0 + }) + + circle_data.sort(key=lambda x: x['total_karma'], reverse=True) + + # Assign ranks efficiently, handling ties if necessary (though current logic doesn't) + for i, c in enumerate(circle_data): + c['rank'] = i + 1 + + # Store as a dictionary for quick lookup by circle ID + self._cached_circle_rankings = {item['id']: item for item in circle_data} + return self._cached_circle_rankings + + def get_total_karma(self, obj): + # Get total karma of learning circle + karma_data = self._all_circle_rankings_data.get(obj.id) + return karma_data['total_karma'] if karma_data else 0 + + def get_rank(self, obj): + # Get the rank of the current circle from pre-calculated rankings + karma_data = self._all_circle_rankings_data.get(obj.id) + return karma_data['rank'] if karma_data else None + + def _get_next_weekday(self, target_day: int): + """Return the date of the next occurrence of target_day (1=Mon…7=Sun). + Always returns a future date — if today is the target day, returns next week.""" + today = datetime.now() + days_until_next = (target_day - today.isoweekday() - 1) % 7 + 1 + return (today + timedelta(days=days_until_next)).date() + + def _get_month_day(self, target_day: int): + """Return the date of the next occurrence of target_day (1-28) in the month. + If today is on or past that day, returns the same day next month.""" + today = datetime.now() + month = today.month + year = today.year + if today.day >= target_day: + month += 1 + if month > 12: + month = 1 + year += 1 + return datetime(year, month, target_day).date() + + def get_next_meetup(self, obj): + # If there's already a pending (not yet reported) meeting, return it. + pending = ( + CircleMeetingLog.objects.filter(circle_id=obj.id, is_report_submitted=False) + .order_by("-meet_time") + .first() + ) + if pending: + return { + **CircleMeetingLogListSerializer(pending).data, + "is_scheduled": True, + } + + # No pending meeting — check if the last meeting was recurring and + # suggest the next occurrence date so the lead can one-click schedule. + last = ( + CircleMeetingLog.objects.filter(circle_id=obj.id) + .order_by("-meet_time") + .first() + ) + if not last or not last.is_recurring: + return None + + if last.recurrence_type == LearningCircleRecurrenceType.WEEKLY.value: + return { + "is_scheduled": False, + "meet_time": self._get_next_weekday(last.recurrence), + } + if last.recurrence_type == LearningCircleRecurrenceType.MONTHLY.value: + return { + "is_scheduled": False, + "meet_time": self._get_month_day(last.recurrence), + } + return {"is_scheduled": False, "meet_time": None} class LearningCircleListMinSerializer(serializers.ModelSerializer): ig = serializers.CharField(source="ig.name", read_only=True) org = serializers.CharField(source="org.title", read_only=True, allow_null=True) - attendees = serializers.SerializerMethodField() + total_members = serializers.IntegerField(read_only=True) + is_joined = serializers.SerializerMethodField() + is_creator = serializers.SerializerMethodField() + # attendees = serializers.SerializerMethodField() + class Meta: + model = LearningCircle + # The 'attendees' field is removed as 'total_members' is used instead. + fields = ["id", "ig", "title", "org", "total_members", "is_joined", "is_creator"] + def to_representation(self, instance): + # Override to add calculated fields that don't exist on the model + data = super().to_representation(instance) + data['total_members'] = self.get_total_members(instance) - def get_attendees(self, obj): + return data + + def get_total_members(self, obj): + """Calculate total number of accepted members in this circle""" + return UserCircleLink.objects.filter( + circle=obj.id, + accepted=True, + ).count() + + def get_is_joined(self, obj): + user_id = self.context.get("user_id") + if not user_id: + return False + joined_ids = self.context.get("joined_circle_ids") + if joined_ids is not None: + return obj.id in joined_ids + return UserCircleLink.objects.filter( + circle=obj.id, + user_id=user_id, + accepted=True, + ).exists() + + def get_is_creator(self, obj): + user_id = self.context.get("user_id") + if not user_id: + return False + return obj.created_by_id == user_id + + # def get_attendees(self, obj): # query = ( # obj.circle_meeting_log_circle_id.prefetch_related( # "circle_meeting_attendance_meet_id" @@ -204,27 +341,43 @@ def get_attendees(self, obj): # } # ) # return data - return [] + # return [] - class Meta: - model = LearningCircle - fields = ["id", "ig", "title", "org", "attendees"] class CircleMeetingLogCreateEditSerializer(serializers.ModelSerializer): - ONLINE_MEET_PLACE_CHOICES = ("Zoom", "Google Meet", "Microsoft Teams", "Other") - + ONLINE_PLATFORM_CHOICES = ("Zoom", "Google Meet", "Microsoft Teams","Discord", "Other") + circle_id = serializers.PrimaryKeyRelatedField( queryset=LearningCircle.objects.all(), required=True ) - meet_link = serializers.URLField(required=False, allow_null=True, allow_blank=True) - meet_place = serializers.CharField(required=False, allow_null=True, allow_blank=True) - coord_x = serializers.FloatField(required=False, allow_null=True) - coord_y = serializers.FloatField(required=False, allow_null=True) - mode = serializers.ChoiceField(choices=[('online', 'Online'), ('offline', 'Offline')], required=True) - + # Online fields + platform = serializers.ChoiceField( + choices=ONLINE_PLATFORM_CHOICES, required=False, allow_null=True, + help_text="Required for online meetings. One of: Zoom, Google Meet, Microsoft Teams, Other" + ) + meet_link = serializers.URLField( + required=False, allow_null=True, + help_text="Required for online meetings. Full URL of the meeting." + ) + # Offline fields + meet_place = serializers.CharField( + required=False, allow_null=True, + help_text="Required for offline meetings. Address/venue name." + ) + coord_x = serializers.FloatField( + required=False, allow_null=True, + help_text="Required for offline meetings. Latitude coordinate." + ) + coord_y = serializers.FloatField( + required=False, allow_null=True, + help_text="Required for offline meetings. Longitude coordinate." + ) + def update(self, instance, validated_data): instance.title = validated_data.get("title", instance.title) - instance.description = validated_data.get("description", instance.description) + instance.is_recurring = validated_data.get("is_recurring", instance.is_recurring) + instance.recurrence_type = validated_data.get("recurrence_type", instance.recurrence_type) + instance.recurrence = validated_data.get("recurrence", instance.recurrence) instance.is_report_needed = validated_data.get( "is_report_needed", instance.is_report_needed ) @@ -247,90 +400,136 @@ def create(self, validated_data): meet_code = self.context.get("meet_code") validated_data["created_by_id"] = user_id validated_data["meet_code"] = meet_code + validated_data.pop("platform", None) # platform is not a DB field; used only in validate() meet = CircleMeetingLog.objects.create(**validated_data) CircleMeetingAttendees.objects.create( meet_id=meet, user_id_id=user_id, is_joined=True, joined_at=datetime.now() ) return meet + def validate_meet_time(self, value): + if value: + aware_time = _aware_meet_time(value) + if aware_time <= DateTimeUtils.get_current_utc_time(): + raise serializers.ValidationError("Meeting time must be in the future.") + return value + def validate(self, attrs): is_report_needed = attrs.get("is_report_needed") report_description = attrs.get("report_description") mode = attrs.get("mode") - meet_place = attrs.get("meet_place") - meet_link = attrs.get("meet_link") - coord_x = attrs.get("coord_x") - coord_y = attrs.get("coord_y") - - # Handle report validation + + # --- Report validation --- if not is_report_needed: attrs["report_description"] = None else: if not report_description: - raise serializers.ValidationError("Report description is required") - - # Mode-specific validation + raise serializers.ValidationError("Report description is required when report is needed.") + + # --- Mode-specific validation --- if mode == "online": - # Validate online requirements - if not meet_link: + # Online: require platform + meet_link; clear offline-only fields + if not attrs.get("platform"): raise serializers.ValidationError( - "Meeting link is required for online mode" + "platform is required for online meetings. " + "Choices: {}".format(", ".join(self.ONLINE_PLATFORM_CHOICES)) ) - if not meet_place: + if not attrs.get("meet_link"): raise serializers.ValidationError( - "Meeting place is required for online mode" + "meet_link (meeting URL) is required for online meetings." ) - if meet_place not in self.ONLINE_MEET_PLACE_CHOICES: + # Clear offline fields — online meetings have no physical location + attrs["coord_x"] = 0.0 + attrs["coord_y"] = 0.0 + attrs["meet_place"] = attrs.get("platform") # store platform name in meet_place + + elif mode == "offline": + # Offline: require address, coordinates; clear online-only fields + if not attrs.get("meet_place"): raise serializers.ValidationError( - "Invalid meet place, meet place should be one of {}".format( - self.ONLINE_MEET_PLACE_CHOICES - ) + "meet_place (venue address) is required for offline meetings." ) - # Clear offline fields for online mode - attrs["coord_x"] = None - attrs["coord_y"] = None - - elif mode == "offline": - # Validate offline requirements + coord_x = attrs.get("coord_x") + coord_y = attrs.get("coord_y") if coord_x is None or coord_y is None: raise serializers.ValidationError( - "Coordinates (coord_x and coord_y) are required for offline mode" + "coord_x (latitude) and coord_y (longitude) are required for offline meetings." ) - if not meet_place: + # Clear online fields + attrs["meet_link"] = None + attrs["platform"] = None + else: + raise serializers.ValidationError( + "mode must be either \"online\" or \"offline\"." + ) + + # --- Recurrence validation --- + is_recurring = attrs.get("is_recurring") + recurrence_type = attrs.get("recurrence_type") + recurrence = attrs.get("recurrence") + if not is_recurring: + attrs["recurrence_type"] = None + attrs["recurrence"] = None + else: + if not recurrence_type or not recurrence: raise serializers.ValidationError( - "Meeting place is required for offline mode" + "recurrence_type and recurrence are required for recurring meetings." ) - # Clear online fields for offline mode - attrs["meet_link"] = None - + if recurrence_type not in LearningCircleRecurrenceType.get_all_values(): + raise serializers.ValidationError( + "Invalid recurrence_type. Valid values: {}".format( + LearningCircleRecurrenceType.get_all_values() + ) + ) + if recurrence_type == LearningCircleRecurrenceType.WEEKLY.value: + if recurrence < 1 or recurrence > 7: + raise serializers.ValidationError( + "recurrence must be between 1-7 for weekly meetings (day of week)." + ) + elif recurrence_type == LearningCircleRecurrenceType.MONTHLY.value: + if recurrence < 1 or recurrence > 28: + raise serializers.ValidationError( + "recurrence must be between 1-28 for monthly meetings (day of month)." + ) + return super().validate(attrs) - def validate_circle_id(self, value): - if CircleMeetingLog.objects.filter( - circle_id=value, is_report_submitted=False - ).exists(): - raise serializers.ValidationError( - "There is already an ongoing meeting for this learning circle" - ) - return value + # def validate_circle_id(self, value): + # if CircleMeetingLog.objects.filter( + # circle_id=value, is_report_submitted=False + # ).exists(): + # raise serializers.ValidationError( + # "There is already an ongoing meeting for this learning circle" + # ) + # return value class Meta: model = CircleMeetingLog fields = [ "circle_id", "title", - "is_report_needed", - "report_description", + "description", + "mode", + # Online fields + "platform", + "meet_link", + # Offline fields + "meet_place", "coord_x", "coord_y", - "meet_place", + # Scheduling "meet_time", "duration", - "meet_link", - "description", - "mode", + # Recurrence + "is_recurring", + "recurrence_type", + "recurrence", + # Report + "is_report_needed", + "report_description", ] + class CircleMeetingLogListSerializer(serializers.ModelSerializer): circle = serializers.CharField(source="circle_id.id", read_only=True) created_by = serializers.CharField(source="created_by_id.full_name", read_only=True) @@ -338,14 +537,10 @@ class CircleMeetingLogListSerializer(serializers.ModelSerializer): is_ended = serializers.SerializerMethodField() def get_is_started(self, obj): - return ( - obj.meet_time + timedelta(hours=1) <= DateTimeUtils.get_current_utc_time() - ) + return meeting_is_started(obj.meet_time) def get_is_ended(self, obj): - return (obj.meet_time + timedelta(hours=obj.duration + 1)) <= datetime.now( - timezone.utc - ) + return meeting_is_ended(obj.meet_time, obj.duration) class Meta: model = CircleMeetingLog @@ -356,6 +551,9 @@ class Meta: "title", "description", "mode", + "is_recurring", + "recurrence_type", + "recurrence", "is_report_needed", "report_description", "coord_x", @@ -422,7 +620,15 @@ class Meta: def get_is_member(self, obj): user_id = self.context.get("user_id") - return obj.created_by_id == user_id + if not user_id: + return False + if obj.created_by_id == user_id: + return True + return UserCircleLink.objects.filter( + circle_id=obj.circle_id_id, + user_id=user_id, + accepted=True, + ).exists() def get_meet_code(self, obj): user_id = self.context.get("user_id") @@ -431,14 +637,10 @@ def get_meet_code(self, obj): return obj.meet_code def get_is_started(self, obj): - return ( - obj.meet_time + timedelta(hours=1) <= DateTimeUtils.get_current_utc_time() - ) + return meeting_is_started(obj.meet_time) def get_is_ended(self, obj): - return (obj.meet_time + timedelta(hours=obj.duration + 1)) <= datetime.now( - timezone.utc - ) + return meeting_is_ended(obj.meet_time, obj.duration) def get_attendees(self, obj): query = ( @@ -455,34 +657,39 @@ def get_attendees(self, obj): data = [] user_id = self.context.get("user_id") cur_user_org = None + cur_user_orgs = set() if user_id: try: - cur_user = ( - User.objects.prefetch_related("user_organization_link_user") - .only("user_organization_link_user__org_id") - .get(id=user_id) + from db.organization import UserOrganizationLink + + cur_user_orgs = set( + UserOrganizationLink.objects.filter(user_id=user_id).values_list( + "org_id", flat=True + ) ) - cur_user_org = cur_user.user_organization_link_user__org_id - except: + except Exception: pass for attendee in query: + attendee_orgs = set( + attendee.user_id.user_organization_link_user.all().values_list( + "org_id", flat=True + ) + ) data.append( { "user_id": attendee.user_id.id, "full_name": attendee.user_id.full_name, "is_joined": attendee.is_joined, + "is_creator": attendee.user_id.id == obj.created_by_id, "is_report_submitted": attendee.is_report_submitted, "profile_pic": attendee.user_id.profile_pic, - "is_same_org": cur_user_org - in attendee.user_id.user_organization_link_user.all().values_list( - "org_id", flat=True - ), + "is_same_org": bool(cur_user_orgs & attendee_orgs), } ) return data -class CircleMeeupPublicSerializer(serializers.ModelSerializer): +class CircleMeetupPublicSerializer(serializers.ModelSerializer): title = serializers.CharField(read_only=True) org = serializers.CharField(source="circle_id.org.title", read_only=True) meet_place = serializers.CharField(read_only=True) @@ -494,14 +701,10 @@ class CircleMeeupPublicSerializer(serializers.ModelSerializer): created_by = serializers.CharField(source="created_by.full_name", read_only=True) def get_is_started(self, obj): - return ( - obj.meet_time + timedelta(hours=1) <= DateTimeUtils.get_current_utc_time() - ) + return meeting_is_started(obj.meet_time) def get_is_ended(self, obj): - return (obj.meet_time + timedelta(hours=obj.duration + 1)) <= datetime.now( - timezone.utc - ) + return meeting_is_ended(obj.meet_time, obj.duration) class Meta: model = CircleMeetingLog @@ -513,6 +716,9 @@ class Meta: "ig_id", "ig_name", "mode", + "is_recurring", + "recurrence_type", + "recurrence", "meet_place", "circle_id", "meet_time", @@ -543,14 +749,10 @@ class CircleMeetupMinSerializer(serializers.ModelSerializer): ig_name = serializers.CharField(source="circle_id.ig.name", read_only=True) def get_is_started(self, obj): - return ( - obj.meet_time + timedelta(hours=1) <= DateTimeUtils.get_current_utc_time() - ) + return meeting_is_started(obj.meet_time) def get_is_ended(self, obj): - return (obj.meet_time + timedelta(hours=obj.duration + 1)) <= datetime.now( - timezone.utc - ) + return meeting_is_ended(obj.meet_time, obj.duration) def get_is_joined(self, obj): if user_id := self.context.get("user_id"): @@ -581,6 +783,9 @@ class Meta: "ig_id", "ig_name", "mode", + "is_recurring", + "recurrence_type", + "recurrence", "meet_place", "is_rsvp", # "meet_code", @@ -645,4 +850,103 @@ class Meta: # return CircleMeetingAttendees.objects.filter( # meet_id__circle_id=obj, # is_joined=True -# ).values('user_id').distinct().count() \ No newline at end of file +# ).values('user_id').distinct().count() + +# --- New Serializers --- + + +class UserCircleListSerializer(serializers.ModelSerializer): + """Serializes a UserCircleLink entry for the 'My Circles' list.""" + circle_id = serializers.CharField(source='circle.id', read_only=True) + title = serializers.CharField(source='circle.title', read_only=True) + ig = serializers.CharField(source='circle.ig.name', read_only=True) + org = serializers.CharField(source='circle.org.title', read_only=True, allow_null=True) + is_lead = serializers.BooleanField(source='lead', read_only=True) + total_members = serializers.SerializerMethodField() + joined_at = serializers.DateTimeField(source='accepted_at', read_only=True) + + def get_total_members(self, obj): + return UserCircleLink.objects.filter(circle=obj.circle_id, accepted=True).count() + + class Meta: + model = UserCircleLink + fields = ['circle_id', 'title', 'ig', 'org', 'is_lead', 'total_members', 'joined_at'] + + +class CircleInviteSerializer(serializers.Serializer): + """Validates the invite request body for POST /invite//.""" + muid = serializers.CharField(required=True, help_text="muid of the user to invite, e.g. user@mulearn") + lead = serializers.BooleanField(required=False, default=False) + + def validate_muid(self, value): + user = User.objects.filter(muid=value).first() + if not user: + raise serializers.ValidationError("No user found with this muid.") + return user.id # Store resolved user_id under the 'muid' key + + def to_internal_value(self, data): + ret = super().to_internal_value(data) + # Rename resolved muid -> user_id for use in the view + ret['user_id'] = ret.pop('muid') + return ret + + +class CircleInviteStatusSerializer(serializers.ModelSerializer): + """Serializes a pending invitation from the invitee's perspective.""" + link_id = serializers.CharField(source='id', read_only=True) + circle_id = serializers.CharField(source='circle.id', read_only=True) + circle_title = serializers.CharField(source='circle.title', read_only=True) + ig = serializers.CharField(source='circle.ig.name', read_only=True) + is_lead_invite = serializers.BooleanField(source='lead', read_only=True) + invited_by = serializers.SerializerMethodField() + created_at = serializers.DateTimeField(read_only=True) + + def get_invited_by(self, obj): + if obj.invited_by: + return { + 'user_id': obj.invited_by.id, + 'full_name': obj.invited_by.full_name, + 'profile_pic': obj.invited_by.profile_pic, + } + return None + + class Meta: + model = UserCircleLink + fields = ['link_id', 'circle_id', 'circle_title', 'ig', + 'is_lead_invite', 'invited_by', 'created_at'] + + +class CircleSentInvitesSerializer(serializers.ModelSerializer): + """Serializes outgoing invites from the lead's perspective.""" + link_id = serializers.CharField(source='id', read_only=True) + user_id = serializers.CharField(source='user.id', read_only=True) + full_name = serializers.CharField(source='user.full_name', read_only=True) + profile_pic = serializers.CharField(source='user.profile_pic', read_only=True, allow_null=True) + muid = serializers.CharField(source='user.muid', read_only=True) + is_lead_invite = serializers.BooleanField(source='lead', read_only=True) + status = serializers.SerializerMethodField() + invited_at = serializers.DateTimeField(source='created_at', read_only=True) + + def get_status(self, obj): + if obj.accepted is None: + return 'pending' + return 'accepted' if obj.accepted else 'rejected' + + class Meta: + model = UserCircleLink + fields = ['link_id', 'user_id', 'full_name', 'profile_pic', 'muid', + 'is_lead_invite', 'status', 'invited_at'] + + +class CircleJoinRequestSerializer(serializers.ModelSerializer): + """Serializes a user-initiated join request (is_invited=False, accepted=None) from the lead's perspective.""" + link_id = serializers.CharField(source='id', read_only=True) + user_id = serializers.CharField(source='user.id', read_only=True) + full_name = serializers.CharField(source='user.full_name', read_only=True) + profile_pic = serializers.CharField(source='user.profile_pic', read_only=True, allow_null=True) + muid = serializers.CharField(source='user.muid', read_only=True) + requested_at = serializers.DateTimeField(source='created_at', read_only=True) + + class Meta: + model = UserCircleLink + fields = ['link_id', 'user_id', 'full_name', 'profile_pic', 'muid', 'requested_at'] diff --git a/api/dashboard/learningcircle/learningcircle_views.py b/api/dashboard/learningcircle/learningcircle_views.py index daaf0f848..1343656c6 100644 --- a/api/dashboard/learningcircle/learningcircle_views.py +++ b/api/dashboard/learningcircle/learningcircle_views.py @@ -1,37 +1,60 @@ -from datetime import datetime, timedelta, timezone +import csv +import gzip +from datetime import timedelta +import uuid + import requests +from django.db import transaction +from django.db.models import Sum, Q +from django.http import HttpResponse from rest_framework.views import APIView -from db.learning_circle import LearningCircle, CircleMeetingLog, CircleMeetingAttendees, UserCircleLink -from utils.utils import CommonUtils -# from db.user import UserInterests -from db.user import UserDomains -from utils.karma import add_karma +from db.learning_circle import ( + LearningCircle, + CircleMeetingLog, + CircleMeetingAttendees, + UserCircleLink, +) +from db.task import KarmaActivityLog +from db.user import UserDomains, User +from django.conf import settings +from api.notification.broadcast_utils import BroadcastUtils +from api.notification.notifications_utils import NotificationUtils +from utils.karma import add_karma, remove_karma +from utils.utils import CommonUtils from utils.permission import CustomizePermission, JWTUtils from utils.response import CustomResponse from utils.types import Lc from utils.utils import DateTimeUtils, generate_code from .learningcircle_serializer import ( + CircleInviteSerializer, + CircleInviteStatusSerializer, + CircleJoinRequestSerializer, CircleMeetingLogCreateEditSerializer, CircleMeetupInfoSerializer, CircleMeetupMinSerializer, - CircleMeeupPublicSerializer, + CircleMeetupPublicSerializer, + CircleSentInvitesSerializer, LearningCircleCreateEditSerialzier, LearningCircleDetailSerializer, LearningCircleListMinSerializer, + UserCircleListSerializer, ) -from django.db.models import Sum, F, Q -from db.user import User -from db.task import ( - KarmaActivityLog, - -) -from collections import defaultdict +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s + + class LearningCircleView(APIView): permission_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve Learning Circle.", + responses={200: LearningCircleDetailSerializer}, + ) def get(self, request, circle_id: str = None): + user_id = JWTUtils.fetch_user_id(request) if circle_id: learning_circle = LearningCircle.objects.get(id=circle_id) # circle_meetings = CircleMeetingLog.objects.filter( @@ -50,12 +73,41 @@ def get(self, request, circle_id: str = None): .order_by("-created_at", "-updated_at") .select_related("ig", "org", "created_by") ) - serializer = LearningCircleListMinSerializer(learning_circles, many=True) - return CustomResponse( - general_message="Learning Circles fetched successfully", - response=serializer.data, - ).get_success_response() + ig_id = request.query_params.get("ig") + if ig_id: + learning_circles = learning_circles.filter(ig_id=ig_id) + + paginated_queryset = CommonUtils.get_paginated_queryset( + learning_circles, + request, + search_fields=["title"], + ) + page = paginated_queryset.get("queryset") + page_circle_ids = [c.id for c in page] + joined_circle_ids = set( + UserCircleLink.objects.filter( + user_id=user_id, + accepted=True, + circle_id__in=page_circle_ids, + ).values_list("circle_id", flat=True) + ) + serializer = LearningCircleListMinSerializer( + page, + many=True, + context={ + "user_id": user_id, + "joined_circle_ids": joined_circle_ids, + }, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated_queryset.get("pagination"), + ) + + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Learning Circle.", + responses={200: LearningCircleDetailSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) serializer = LearningCircleCreateEditSerialzier( @@ -70,14 +122,36 @@ def post(self, request): add_karma( user_id, Lc.MEET_CREATE_HASHTAG.value, user_id, Lc.MEET_CREATE_KARMA.value ) + + # Broadcast new LC creation to all campus members (if the circle belongs to a campus) + creator = User.objects.filter(id=user_id).first() + if creator and result.org_id: + BroadcastUtils.create_broadcast( + title='New Learning Circle Created', + description=f'A new Learning Circle "{result.title}" has been formed in your campus!', + target_type='campus', + target_id=result.org_id, + created_by=creator, + expiry_key='lc_created', + url=f'/dashboard/learning-circle/', + ) + return CustomResponse( general_message="Learning Circle created successfully", response={"circle_id": result.id}, ).get_success_response() + @extend_schema(tags=['Dashboard - Learningcircle'], description="Update Learning Circle.", + responses={200: LearningCircleDetailSerializer}, + ) def put(self, request, circle_id: str): user_id = JWTUtils.fetch_user_id(request) - learning_circle = LearningCircle.objects.get(id=circle_id) + try: + learning_circle = LearningCircle.objects.get(id=circle_id) + except LearningCircle.DoesNotExist: + return CustomResponse( + general_message="Learning Circle not found" + ).get_failure_response() if learning_circle.created_by_id != user_id: return CustomResponse( general_message="You do not have permission to edit this Learning Circle" @@ -93,14 +167,24 @@ def put(self, request, circle_id: str): general_message="Learning Circle update failed", response=serializer.errors, ).get_failure_response() - serializer.update(learning_circle, serializer.validated_data) + + serializer.save() + return CustomResponse( general_message="Learning Circle updated successfully" ).get_success_response() + @extend_schema(tags=['Dashboard - Learningcircle'], description="Delete Learning Circle.", + responses={200: LearningCircleDetailSerializer}, + ) def delete(self, request, circle_id: str): user_id = JWTUtils.fetch_user_id(request) - learning_circle = LearningCircle.objects.get(id=circle_id) + try: + learning_circle = LearningCircle.objects.get(id=circle_id) + except LearningCircle.DoesNotExist: + return CustomResponse( + general_message="Learning Circle not found" + ).get_failure_response() if learning_circle.created_by_id != user_id: return CustomResponse( general_message="You do not have permission to delete this Learning Circle" @@ -112,11 +196,20 @@ def delete(self, request, circle_id: str): class LearningCircleMeetingInfoAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve Learning Circle Meeting Info.", + responses={200: CircleMeetupInfoSerializer}, + ) def get(self, request, meet_id: str): - user_id = None - if JWTUtils.is_jwt_authenticated(request): - user_id = JWTUtils.fetch_user_id(request) - meet = CircleMeetingLog.objects.get(id=meet_id) + user_id = JWTUtils.fetch_user_id(request) + meet = CircleMeetingLog.objects.filter(id=meet_id).first() + if not meet: + return CustomResponse( + general_message="Meeting not found" + ).get_failure_response() serializer = CircleMeetupInfoSerializer(meet, context={"user_id": user_id}) return CustomResponse( general_message="Meeting fetched successfully", @@ -125,8 +218,17 @@ def get(self, request, meet_id: str): class LearningCircleMeetingListView(APIView): + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve Learning Circle Meeting List.", + responses={200: CircleMeetupMinSerializer}, + ) def get(self, request, circle_id: str): - learning_circle = LearningCircle.objects.get(id=circle_id) + learning_circle = LearningCircle.objects.filter(id=circle_id).first() + if not learning_circle: + return CustomResponse( + general_message="Learning Circle not found" + ).get_failure_response() circle_meetings = CircleMeetingLog.objects.filter(circle_id=learning_circle) serializer = CircleMeetupMinSerializer(circle_meetings, many=True) return CustomResponse( @@ -138,11 +240,30 @@ def get(self, request, circle_id: str): class LearningCircleMeetingView(APIView): permission_classes = [CustomizePermission] - def post(self, request): + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Create Learning Circle Meeting.", + request=CircleMeetingLogCreateEditSerializer, + responses={200: CircleMeetingLogCreateEditSerializer}, + ) + def post(self, request, circle_id: str): user_id = JWTUtils.fetch_user_id(request) + try: + circle = LearningCircle.objects.get(id=circle_id) + except LearningCircle.DoesNotExist: + return CustomResponse( + general_message="Learning Circle not found" + ).get_failure_response() + # Only accepted members (including the lead/creator) may create meetings. + if not _is_member_or_creator(circle, user_id): + return CustomResponse( + general_message="Only circle members can create meetings" + ).get_failure_response() meet_code = generate_code() + request_data = request.data.copy() + request_data['circle_id'] = circle_id serializer = CircleMeetingLogCreateEditSerializer( - data=request.data, context={"user_id": user_id, "meet_code": meet_code} + data=request_data, context={"user_id": user_id, "meet_code": meet_code} ) if not serializer.is_valid(): return CustomResponse( @@ -154,9 +275,16 @@ def post(self, request): general_message="Circle Meeting created successfully" ).get_success_response() + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Update Learning Circle Meeting.", + responses={200: CircleMeetingLogCreateEditSerializer}, + ) def put(self, request, meet_id: str): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error if circle_meeting.created_by_id != user_id: return CustomResponse( general_message="You do not have permission to edit this Circle Meeting" @@ -172,11 +300,14 @@ def put(self, request, meet_id: str): general_message="Circle Meeting update failed", response=serializer.errors, ).get_failure_response() - serializer.update(circle_meeting, serializer.validated_data) + serializer.save() return CustomResponse( general_message="Circle Meeting updated successfully" ).get_success_response() + @extend_schema(tags=['Dashboard - Learningcircle'], description="Delete Learning Circle Meeting.", + responses={200: CircleMeetingLogCreateEditSerializer}, + ) def delete(self, request, meet_id: str): user_id = JWTUtils.fetch_user_id(request) circle_meeting = CircleMeetingLog.objects.select_related( @@ -195,18 +326,28 @@ def delete(self, request, meet_id: str): class LearningCircleRSVPAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Learning Circle R S V P.", + responses={200: OpenApiResponse(description="RSVP registered successfully. No response body.")}, + ) def post(self, request, meet_id: str): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) - is_meet_started = ( - circle_meeting.meet_time <= DateTimeUtils.get_current_utc_time() - ) - is_meet_ended = ( - circle_meeting.meet_time + timedelta(hours=circle_meeting.duration + 2) - ) <= DateTimeUtils.get_current_utc_time() - if is_meet_started or is_meet_ended: + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error + if not _is_member_or_creator(circle_meeting.circle_id, user_id): + return CustomResponse( + general_message="Only circle members can RSVP to this meeting" + ).get_failure_response() + now = DateTimeUtils.get_current_utc_time() + if circle_meeting.meet_time <= now: + return CustomResponse( + general_message="Meeting has already started" + ).get_failure_response() + if ( + circle_meeting.meet_time + timedelta(hours=circle_meeting.duration) + ) <= now: return CustomResponse( - general_message="Meeting has already started or ended" + general_message="Meeting has already ended" ).get_failure_response() attendee = CircleMeetingAttendees.objects.filter( meet_id=circle_meeting, user_id_id=user_id @@ -229,19 +370,26 @@ def post(self, request, meet_id: str): class LearningCircleJoinAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Learning Circle Join.", + responses={200: OpenApiResponse(description="Joined the meeting successfully. No response body.")}, + ) def post(self, request, meet_id: str): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) - is_meet_started = circle_meeting.meet_time <= ( - DateTimeUtils.get_current_utc_time() + timedelta(hours=2) - ) - if not is_meet_started: + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error + if not _is_member_or_creator(circle_meeting.circle_id, user_id): + return CustomResponse( + general_message="Only circle members can join this meeting" + ).get_failure_response() + now = DateTimeUtils.get_current_utc_time() + if circle_meeting.meet_time > now: return CustomResponse( general_message="You can only join the Circle Meeting after it has started" ).get_failure_response() is_meet_ended = ( - circle_meeting.meet_time + timedelta(hours=circle_meeting.duration + 2) - ) <= DateTimeUtils.get_current_utc_time() + circle_meeting.meet_time + timedelta(hours=circle_meeting.duration) + ) <= now if is_meet_ended: return CustomResponse( general_message="The Circle Meeting has already ended" @@ -275,9 +423,8 @@ def post(self, request, meet_id: str): is_joined=is_joined, joined_at=joined_at, ) - return CustomResponse( - general_message="You have successfully joined the Circle Meeting" - ).get_success_response() + # Award join karma for both paths — RSVP-then-join and first-time direct + # join — so the reward does not depend on whether the user RSVP'd first. add_karma( user_id, Lc.MEET_JOIN_HASHTAG.value, user_id, Lc.MEET_JOIN_KARMA.value ) @@ -285,9 +432,14 @@ def post(self, request, meet_id: str): general_message=("You have successfully joined the Circle Meeting") ).get_success_response() + @extend_schema(tags=['Dashboard - Learningcircle'], description="Delete Learning Circle Join.", + responses={200: OpenApiResponse(description="Removed from meetup attendee list. No response body.")}, + ) def delete(self, request, meet_id: str): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error attendee = CircleMeetingAttendees.objects.filter( meet_id=circle_meeting, user_id_id=user_id ).first() @@ -306,9 +458,22 @@ def delete(self, request, meet_id: str): class LearningCircleAttendeeReportAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Learningcircle'], description="Retrieve Learning Circle Attendee Report.", + responses={200: inline_serializer( + "LearningCircleAttendeeReportGetResponse", + fields={ + "report": s.CharField(allow_null=True, help_text="Attendee's report text"), + "report_link": s.CharField(allow_null=True, help_text="URL link to the attendee's report"), + }, + )}, + ) def get(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error attendee = CircleMeetingAttendees.objects.filter( meet_id=circle_meeting, user_id_id=user_id ).first() @@ -328,9 +493,14 @@ def get(self, request, meet_id): }, ).get_success_response() + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Learning Circle Attendee Report.", + responses={200: OpenApiResponse(description="Attendee report submitted successfully. No response body.")}, + ) def post(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error attendee = CircleMeetingAttendees.objects.filter( meet_id=circle_meeting, user_id_id=user_id ).first() @@ -362,9 +532,14 @@ def post(self, request, meet_id): general_message="You have successfully submitted the report" ).get_success_response() + @extend_schema(tags=['Dashboard - Learningcircle'], description="Delete Learning Circle Attendee Report.", + responses={200: OpenApiResponse(description="Attendee report deleted successfully. No response body.")}, + ) def delete(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error attendee = CircleMeetingAttendees.objects.filter( meet_id=circle_meeting, user_id_id=user_id ).first() @@ -384,6 +559,11 @@ def delete(self, request, meet_id): attendee.report_text = None attendee.report_link = None attendee.save() + remove_karma( + user_id, + Lc.ATTENDEE_REPORT_SUBMIT_HASHTAG.value, + Lc.ATTENDEE_REPORT_SUBMIT_KARMA.value, + ) return CustomResponse( general_message="You have successfully deleted the report" ).get_success_response() @@ -392,10 +572,38 @@ def delete(self, request, meet_id): class LearningCircleReportAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Learningcircle'], description="Retrieve Learning Circle Report.", + responses={200: inline_serializer( + "LearningCircleReportGetResponse", + fields={ + "is_report_submitted": s.BooleanField(help_text="Whether the meeting report has been submitted by the organizer"), + "report": s.CharField(allow_null=True, help_text="Meeting report text submitted by the organizer"), + "attendees": s.ListField( + child=inline_serializer( + "LearningCircleReportAttendeeItem", + fields={ + "user_id": s.CharField(help_text="UUID of the attendee"), + "full_name": s.CharField(help_text="Full name of the attendee"), + "muid": s.CharField(help_text="Unique mulearn ID of the attendee"), + "is_lc_approved": s.BooleanField(allow_null=True, help_text="Whether the organizer approved this attendee's participation"), + "report": s.CharField(allow_null=True, help_text="Attendee's own report text"), + "report_link": s.CharField(allow_null=True, help_text="URL to the attendee's report"), + }, + ), + help_text="List of attendees who joined the meeting", + ), + }, + )}, + ) def get(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) - if circle_meeting.created_by_id != user_id: + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error + circle = circle_meeting.circle_id + if circle_meeting.created_by_id != user_id and not _is_lead_or_creator( + circle, user_id + ): return CustomResponse( general_message="You do not have permission to view the report" ).get_failure_response() @@ -421,9 +629,14 @@ def get(self, request, meet_id): }, ).get_success_response() + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Learning Circle Report.", + responses={200: OpenApiResponse(description="Meeting report submitted successfully. No response body.")}, + ) def post(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error if circle_meeting.created_by_id != user_id: return CustomResponse( general_message="You do not have permission to submit the report" @@ -442,7 +655,6 @@ def post(self, request, meet_id): return CustomResponse( general_message="Please provide the report" ).get_failure_response() - karma_user_ids = [] for attendee_id, approved in attendees.items(): attendee = CircleMeetingAttendees.objects.filter( meet_id=circle_meeting, user_id_id=attendee_id @@ -455,26 +667,38 @@ def post(self, request, meet_id): return CustomResponse( general_message="Attendee has not submitted the report" ).get_failure_response() - attendee.is_lc_approved = approved - attendee.save() - if attendee.is_lc_approved: - karma_user_ids.append(attendee_id) - circle_meeting.is_report_submitted = True - circle_meeting.report_text = report - circle_meeting.save() - add_karma( - karma_user_ids, - Lc.LC_REPORT_HASHTAG.value, - user_id, - Lc.LC_REPORT_KARMA.value, - ) + karma_user_ids = [] + with transaction.atomic(): + for attendee_id, approved in attendees.items(): + attendee = CircleMeetingAttendees.objects.filter( + meet_id=circle_meeting, user_id_id=attendee_id + ).first() + attendee.is_lc_approved = approved + attendee.save() + if approved: + karma_user_ids.append(attendee_id) + circle_meeting.is_report_submitted = True + circle_meeting.report_text = report + circle_meeting.save() + if karma_user_ids: + add_karma( + karma_user_ids, + Lc.LC_REPORT_HASHTAG.value, + user_id, + Lc.LC_REPORT_KARMA.value, + ) return CustomResponse( general_message="The report has been submitted successfully" ).get_success_response() + @extend_schema(tags=['Dashboard - Learningcircle'], description="Delete Learning Circle Report.", + responses={200: OpenApiResponse(description="Meeting report deleted successfully. No response body.")}, + ) def delete(self, request, meet_id): user_id = JWTUtils.fetch_user_id(request) - circle_meeting = CircleMeetingLog.objects.get(id=meet_id) + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error if circle_meeting.created_by_id != user_id: return CustomResponse( general_message="You do not have permission to delete the report" @@ -490,18 +714,144 @@ def delete(self, request, meet_id): attendees = CircleMeetingAttendees.objects.filter( meet_id=circle_meeting, is_joined=True ) + karma_user_ids = list( + attendees.filter(is_lc_approved=True).values_list("user_id_id", flat=True) + ) for attendee in attendees: attendee.is_lc_approved = False attendee.save() circle_meeting.is_report_submitted = False circle_meeting.report_text = None circle_meeting.save() + if karma_user_ids: + remove_karma( + karma_user_ids, + Lc.LC_REPORT_HASHTAG.value, + Lc.LC_REPORT_KARMA.value, + ) return CustomResponse( general_message="The report has been deleted successfully" ).get_success_response() +class LearningCircleReportExportAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Learningcircle'], + description=( + "Export a meeting's minutes and every attendee's individual report " + "as a single CSV file. Accessible to the meeting creator, circle " + "lead, or circle creator." + ), + responses={200: OpenApiResponse(description="CSV file download.")}, + ) + def get(self, request, meet_id): + user_id = JWTUtils.fetch_user_id(request) + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error + circle = circle_meeting.circle_id + if circle_meeting.created_by_id != user_id and not _is_lead_or_creator( + circle, user_id + ): + return CustomResponse( + general_message="You do not have permission to export the report" + ).get_failure_response() + + attendees = ( + CircleMeetingAttendees.objects.filter( + meet_id=circle_meeting, is_joined=True + ) + .select_related("user_id") + .order_by("user_id__full_name") + ) + + meet_time = circle_meeting.meet_time + meet_time_str = ( + meet_time.strftime("%Y-%m-%d %H:%M:%S") if meet_time else "" + ) + safe_title = "".join( + ch if ch.isalnum() or ch in ("-", "_") else "_" + for ch in (circle_meeting.title or "meeting") + ).strip("_") or "meeting" + filename = f"meeting_report_{safe_title}_{circle_meeting.meet_code or circle_meeting.id}.csv" + + buffer = HttpResponse(content_type="text/csv") + + def clean(value): + if value is None: + return "" + text = str(value).replace("\r\n", " ").replace("\n", " ").replace("\r", " ").strip() + if text and text[0] in ("=", "+", "-", "@"): + text = "'" + text + return text + + writer = csv.writer(buffer, lineterminator="\n") + + writer.writerow(["Meeting Report"]) + writer.writerow(["Learning Circle", clean(circle.title)]) + writer.writerow(["Interest Group", clean(getattr(circle.ig, "name", ""))]) + writer.writerow(["Meeting Title", clean(circle_meeting.title)]) + writer.writerow(["Description", clean(circle_meeting.description)]) + writer.writerow(["Mode", clean(circle_meeting.mode)]) + writer.writerow(["Meeting Time", clean(meet_time_str)]) + writer.writerow(["Duration (hours)", clean(circle_meeting.duration)]) + writer.writerow(["Place", clean(circle_meeting.meet_place)]) + writer.writerow(["Meet Link", clean(circle_meeting.meet_link)]) + writer.writerow(["Meet Code", clean(circle_meeting.meet_code)]) + writer.writerow( + ["Report Submitted", "Yes" if circle_meeting.is_report_submitted else "No"] + ) + writer.writerow( + ["LC Approved", "Yes" if circle_meeting.is_approved else "No"] + ) + writer.writerow(["Total Attendees", attendees.count()]) + writer.writerow([]) + writer.writerow(["Minutes of Meeting"]) + writer.writerow([clean(circle_meeting.report_text)]) + writer.writerow([]) + writer.writerow(["Attendee Reports"]) + writer.writerow( + [ + "MuID", + "Full Name", + "Email", + "Report Submitted", + "LC Approved", + "Report", + "Report Link", + ] + ) + for attendee in attendees: + user = attendee.user_id + writer.writerow( + [ + clean(user.muid), + clean(user.full_name), + clean(user.email), + "Yes" if attendee.is_report_submitted else "No", + "Yes" if attendee.is_lc_approved else "No", + clean(attendee.report_text), + clean(attendee.report_link), + ] + ) + + response = HttpResponse( + gzip.compress(buffer.content), + content_type="text/csv", + ) + response["Content-Disposition"] = f'attachment; filename="{filename}"' + response["Content-Encoding"] = "gzip" + return response + + class LearningCircleMeetingPublicListView(APIView): + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve Learning Circle Meeting Public List.", + responses={200: CircleMeetupPublicSerializer}, + ) def get(self, request): request_data = request.query_params ig_id = request_data.get("ig_id", None) @@ -519,7 +869,7 @@ def get(self, request): search_fields=["title", "description", "circle_id__ig__name"], ) - serializer = CircleMeeupPublicSerializer( + serializer = CircleMeetupPublicSerializer( paginated_queryset.get("queryset"), many=True ) @@ -530,6 +880,11 @@ def get(self, request): class LearningCircleMeetingListAPI(APIView): + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve Learning Circle Meeting List.", + responses={200: CircleMeetupMinSerializer}, + ) def get(self, request): request_data = request.query_params @@ -549,10 +904,6 @@ def get(self, request): general_message="User not authenticated" ).get_failure_response(status_code=401) if saved or participated: - if not user_id: - return CustomResponse( - general_message="User not authenticated" - ).get_failure_response() category = "all" if saved and participated: return CustomResponse( @@ -612,126 +963,46 @@ def get(self, request): - -class LearningCircleBasicDetailsView(APIView): - - def get(self, request, circle_id): - try: - circle = LearningCircle.objects.select_related('ig').get(id=circle_id) - member_count = UserCircleLink.objects.filter( - circle=circle_id, - accepted=True, - accepted__isnull=False - ).count() - # Calculate total karma - member_ids = UserCircleLink.objects.filter( - circle=circle_id, - accepted=True, - accepted__isnull=False - ).values_list('user_id', flat=True) - - total_karma = KarmaActivityLog.objects.filter( - user_id__in=member_ids, - task__ig=circle.ig - ).aggregate(total=Sum('karma'))['total'] or 0 - - # Calculate circle rankings using Django ORM - circle_karma = self.get_circle_rankings() - - # Find the rank of the current circle - circle_rank = next((item['rank'] for item in circle_karma if item['id'] == circle_id), None) - - # Get pending invitations count - pending_invites = UserCircleLink.objects.filter( - circle_id=circle_id, - is_invited=1, - accepted__isnull=True - ).count() - - response_data = { - 'circle_id': circle.id, - 'circle_title': circle.title, - 'ig_id': circle.ig_id, - 'ig_name': circle.ig.name, - 'member_count': member_count, - 'total_karma': total_karma, - 'rank': circle_rank, - 'pending_invites': pending_invites, - } - - return CustomResponse( - general_message="Learning Circle basic details fetched successfully", - response=response_data - ).get_success_response() - - except LearningCircle.DoesNotExist: - return CustomResponse( - general_message="Learning Circle not found" - ).get_failure_response() - - @staticmethod - def get_circle_rankings(): - - user_circle_links = UserCircleLink.objects.filter(accepted=True).values('circle_id', 'user_id') - - circle_to_users = defaultdict(list) - for link in user_circle_links: - circle_to_users[link['circle_id']].append(link['user_id']) - - circle_igs = dict(LearningCircle.objects.values_list('id', 'ig_id')) - - user_karma = ( - KarmaActivityLog.objects - .values('user_id', 'task__ig') - .annotate(total_karma=Sum('karma')) - ) - - # Build a user-IG karma map - user_ig_karma = defaultdict(int) - for entry in user_karma: - user_ig_karma[(entry['user_id'], entry['task__ig'])] += entry['total_karma'] - - # Build circle karma list - circle_data = [] - for circle_id, user_ids in circle_to_users.items(): - ig_id = circle_igs.get(circle_id) - total = sum(user_ig_karma.get((uid, ig_id), 0) for uid in user_ids) - circle_data.append({ - 'id': circle_id, - 'total_karma': total - }) - - # Include circles with no members (0 karma) - all_circle_ids = set(circle_igs.keys()) - existing_ids = {c['id'] for c in circle_data} - for missing_id in all_circle_ids - existing_ids: - circle_data.append({ - 'id': missing_id, - 'total_karma': 0 - }) - - # Sort and assign rank - circle_data.sort(key=lambda x: x['total_karma'], reverse=True) - for i, c in enumerate(circle_data): - c['rank'] = i + 1 - - return circle_data - - class LearningCircleMemberDetailsView(APIView): - + @extend_schema(tags=['Dashboard - Learningcircle'], description="Retrieve Learning Circle Member Details.", + responses={200: inline_serializer( + "LearningCircleMemberDetailsResponse", + fields={ + "owner": inline_serializer( + "LearningCircleMemberDetailsOwner", + fields={ + "id": s.CharField(help_text="UUID of the circle creator"), + "full_name": s.CharField(help_text="Full name of the circle creator"), + "profile_pic": s.CharField(allow_null=True, help_text="Profile picture URL of the circle creator"), + "muid": s.CharField(help_text="Unique mulearn ID of the circle creator"), + }, + ), + "members": s.ListField( + child=inline_serializer( + "LearningCircleMemberItem", + fields={ + "id": s.CharField(help_text="UUID of the member"), + "full_name": s.CharField(help_text="Full name of the member"), + "profile_pic": s.CharField(allow_null=True, help_text="Profile picture URL of the member"), + "muid": s.CharField(help_text="Unique mulearn ID of the member"), + "ig_karma": s.IntegerField(help_text="Total karma earned by the member in this circle's interest group"), + "is_leader": s.BooleanField(help_text="Whether this member holds the lead role in the circle"), + }, + ), + help_text="Accepted members sorted by ig_karma descending", + ), + }, + )}, + ) def get(self, request, circle_id): try: - circle = LearningCircle.objects.select_related('ig').get(id=circle_id) + circle = LearningCircle.objects.select_related('ig', 'created_by').get(id=circle_id) member_links = UserCircleLink.objects.filter( circle=circle_id, accepted=True, - accepted__isnull=False # Exclude None values ).select_related('user') - - leaders = set(link.user_id for link in member_links if link.lead) member_ids = [link.user_id for link in member_links] @@ -765,264 +1036,549 @@ def get(self, request, circle_id): # Sort by karma (highest first) member_details = sorted(member_details, key=lambda x: x['ig_karma'], reverse=True) + + # Build owner details from the circle's created_by FK + owner = circle.created_by + owner_details = { + 'id': owner.id, + 'full_name': owner.full_name, + 'profile_pic': owner.profile_pic, + 'muid': owner.muid, + } return CustomResponse( general_message="Learning Circle member details fetched successfully", - response=member_details + response={ + 'owner': owner_details, + 'members': member_details, + } ).get_success_response() except LearningCircle.DoesNotExist: return CustomResponse( general_message="Learning Circle not found" ).get_failure_response() - - -class LearningCircleManageRequestsView(APIView): - - def get(self, request, circle_id): + +# --- New Views --- + + +def _is_lead_or_creator(circle, user_id): + """Returns True if the user is the circle creator OR has lead=True in UserCircleLink.""" + if circle.created_by_id == user_id: + return True + return UserCircleLink.objects.filter( + circle=circle, user_id=user_id, accepted=True, lead=True + ).exists() + + +def _is_member_or_creator(circle, user_id): + """Returns True if the user is the circle creator OR an accepted member (incl. leads).""" + if circle.created_by_id == user_id: + return True + return UserCircleLink.objects.filter( + circle=circle, user_id=user_id, accepted=True + ).exists() + + +def _get_meeting_or_response(meet_id): + """Fetch a CircleMeetingLog by id, or return (None, failure_response) when missing. + + Prevents an unhandled DoesNotExist (HTTP 500) when a stale, deleted or + mistyped meeting link is opened. Usage: + + circle_meeting, error = _get_meeting_or_response(meet_id) + if error: + return error + """ + meeting = CircleMeetingLog.objects.filter(id=meet_id).first() + if meeting is None: + return None, CustomResponse( + general_message="Meeting not found" + ).get_failure_response() + return meeting, None + + +class UserCircleListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve User Circle List.", + responses={200: UserCircleListSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + links = ( + UserCircleLink.objects.filter(user_id=user_id, accepted=True) + .select_related('circle__ig', 'circle__org') + ) + from .learningcircle_serializer import UserCircleListSerializer + serializer = UserCircleListSerializer(links, many=True) + return CustomResponse( + general_message="User circles fetched successfully", + response=serializer.data, + ).get_success_response() + + +class CircleJoinAPI(APIView): + """ + POST — member sends a join request (pending lead approval) + GET — lead/creator lists all pending join requests + PATCH — lead/creator accepts or rejects a pending join request + """ + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Circle Join.", + responses={200: CircleJoinRequestSerializer}, + ) + def post(self, request, circle_id: str): + """User sends a join request. Creates a pending UserCircleLink (accepted=None).""" + user_id = JWTUtils.fetch_user_id(request) + try: circle = LearningCircle.objects.get(id=circle_id) - - pending_requests = UserCircleLink.objects.filter( - circle_id=circle_id, - accepted__isnull=True - ).select_related('user') - - if not pending_requests.exists(): + except LearningCircle.DoesNotExist: + return CustomResponse(general_message="Learning Circle not found").get_failure_response() + + if circle.created_by_id == user_id: + return CustomResponse( + general_message="You are the creator of this circle" + ).get_failure_response() + + existing = UserCircleLink.objects.filter(circle=circle, user_id=user_id).first() + + if existing: + if existing.accepted is True: return CustomResponse( - general_message="No pending requests to join.", - response=[] + general_message="You are already a member of this circle" + ).get_failure_response() + # Accept a pending invite sent by a lead + if existing.is_invited and existing.accepted is None: + existing.accepted = True + existing.accepted_at = DateTimeUtils.get_current_utc_time() + existing.save() + return CustomResponse( + general_message="Invitation accepted. You have joined the circle." ).get_success_response() - - pending_requests_data = [ - { - "user_id": request.user_id, - "full_name": request.user.full_name, - "email": request.user.email - } - for request in pending_requests - ] - + # A pending join request already exists + if not existing.is_invited and existing.accepted is None: + return CustomResponse( + general_message="Join request already sent. Waiting for lead approval." + ).get_failure_response() + # Any other link state (e.g. previously rejected) return CustomResponse( - general_message="Pending requests fetched successfully.", - response=pending_requests_data + general_message="You have a previous link to this circle that prevents joining. Contact the circle lead." + ).get_failure_response() + + UserCircleLink.objects.create( + id=str(uuid.uuid4()), + user_id=user_id, + circle=circle, + lead=False, + is_invited=False, + accepted=None, + accepted_at=None, + ) + + # Notify the circle lead about the new join request + lead_link = UserCircleLink.objects.filter(circle=circle, lead=True).select_related('user').first() + requester = User.objects.filter(id=user_id).first() + if lead_link and requester: + NotificationUtils.insert_notification( + user=lead_link.user, + title="Member Request", + description=f"{requester.full_name} has requested to join your learning circle '{circle.title}'", + button="View", + url=f"{settings.FR_DOMAIN_NAME}/dashboard/learningcircle/{circle_id}/", + created_by=requester, + ) + + return CustomResponse( + general_message="Join request sent. Waiting for lead approval." + ).get_success_response() + + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve Circle Join.", + responses={200: CircleJoinRequestSerializer}, + ) + def get(self, request, circle_id: str): + """Lead/creator lists all pending join requests for this circle.""" + user_id = JWTUtils.fetch_user_id(request) + + try: + circle = LearningCircle.objects.get(id=circle_id) + except LearningCircle.DoesNotExist: + return CustomResponse(general_message="Learning Circle not found").get_failure_response() + + if not _is_lead_or_creator(circle, user_id): + return CustomResponse( + general_message="Only the circle lead or creator can view join requests" + ).get_failure_response() + + pending = UserCircleLink.objects.filter( + circle=circle, is_invited=False, accepted__isnull=True + ).select_related('user') + + from .learningcircle_serializer import CircleJoinRequestSerializer + serializer = CircleJoinRequestSerializer(pending, many=True) + return CustomResponse( + general_message="Pending join requests fetched successfully", + response=serializer.data, + ).get_success_response() + + @extend_schema(tags=['Dashboard - Learningcircle'], description="Partially update Circle Join.", + responses={200: CircleJoinRequestSerializer}, + ) + def patch(self, request, circle_id: str): + """Lead/creator accepts or rejects a pending join request. Body: {link_id, action: accept|reject}""" + user_id = JWTUtils.fetch_user_id(request) + + try: + circle = LearningCircle.objects.get(id=circle_id) + except LearningCircle.DoesNotExist: + return CustomResponse(general_message="Learning Circle not found").get_failure_response() + + if not _is_lead_or_creator(circle, user_id): + return CustomResponse( + general_message="Only the circle lead or creator can manage join requests" + ).get_failure_response() + + link_id = request.data.get("link_id") + if not link_id: + return CustomResponse(general_message="link_id is required").get_failure_response() + + try: + link = UserCircleLink.objects.get( + id=link_id, circle=circle, is_invited=False, accepted__isnull=True + ) + except UserCircleLink.DoesNotExist: + return CustomResponse( + general_message="Pending join request not found" + ).get_failure_response() + + action = request.data.get("action") + if action not in ("accept", "reject"): + return CustomResponse( + general_message="Invalid action. Must be 'accept' or 'reject'" + ).get_failure_response() + + lead_user = User.objects.filter(id=user_id).first() + requester_user = link.user + + if action == "accept": + link.accepted = True + link.accepted_at = DateTimeUtils.get_current_utc_time() + link.save() + # Notify the requester that they have been accepted + NotificationUtils.insert_notification( + user=requester_user, + title="Request Approved", + description=f"Your request to join the learning circle '{circle.title}' has been approved.", + button="View", + url=f"{settings.FR_DOMAIN_NAME}/dashboard/learningcircle/{circle_id}/", + created_by=lead_user, + ) + return CustomResponse( + general_message="Join request accepted. User is now a member." ).get_success_response() + link.accepted = False + link.save() + # Notify the requester that they have been rejected + NotificationUtils.insert_notification( + user=requester_user, + title="Request Rejected", + description=f"Your request to join the learning circle '{circle.title}' has been rejected.", + button=None, + url=None, + created_by=lead_user, + ) + return CustomResponse( + general_message="Join request rejected." + ).get_success_response() + + +class CircleMemberAddAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Circle Member Add.", + responses={200: OpenApiResponse(description="Member added to the circle successfully. No response body.")}, + ) + def post(self, request, circle_id: str): + user_id = JWTUtils.fetch_user_id(request) + + try: + circle = LearningCircle.objects.get(id=circle_id) except LearningCircle.DoesNotExist: + return CustomResponse(general_message="Learning Circle not found").get_failure_response() + + if not _is_lead_or_creator(circle, user_id): return CustomResponse( - general_message="Learning Circle not found." + general_message="Only the circle lead or creator can add members" ).get_failure_response() - except Exception as e: + target_muid = request.data.get("muid") + if not target_muid: + return CustomResponse(general_message="muid is required").get_failure_response() + + try: + target_user = User.objects.get(muid=target_muid) + except User.DoesNotExist: + return CustomResponse(general_message="No user found with this muid").get_failure_response() + + target_user_id = target_user.id + + already_member = UserCircleLink.objects.filter( + circle=circle, user_id=target_user_id, accepted=True + ).exists() + if already_member: return CustomResponse( - general_message=f"An error occurred: {str(e)}" + general_message="User is already a member of this circle" ).get_failure_response() - def post(self, request, circle_id): + # Remove any pending invite if exists to avoid duplicate + UserCircleLink.objects.filter( + circle=circle, user_id=target_user_id, accepted__isnull=True + ).delete() + + UserCircleLink.objects.create( + id=str(uuid.uuid4()), + user_id=target_user_id, + circle=circle, + lead=False, + is_invited=False, + accepted=True, + accepted_at=DateTimeUtils.get_current_utc_time(), + ) + return CustomResponse(general_message="Member added successfully").get_success_response() + + +class CircleInviteAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Create Circle Invite.", + request=CircleInviteSerializer, + responses={200: CircleInviteSerializer}, + ) + def post(self, request, circle_id: str): + user_id = JWTUtils.fetch_user_id(request) + try: - user_id = request.data.get('user_id') - action = request.data.get('action') - - if action not in ["accept", "reject"]: - return CustomResponse( - general_message="Invalid action. Use 'accept' or 'reject'." - ).get_failure_response() - - admin_user_id = request.user.id - is_admin = UserCircleLink.objects.filter( - circle_id=circle_id, - user_id=admin_user_id, - lead=True, - accepted=True - ).exists() - - if not is_admin: - return CustomResponse( - general_message="You do not have permission to manage this circle." - ).get_failure_response() - - link = UserCircleLink.objects.filter( - circle_id=circle_id, - user_id=user_id, - accepted__isnull=True - ).first() - - if not link: - return CustomResponse( - general_message="No pending request found for this user." - ).get_failure_response() - - if action == "accept": - link.accepted = True - link.save() - return CustomResponse( - general_message="User request accepted successfully." - ).get_success_response() - - if action == "reject": - link.delete() - return CustomResponse( - general_message="User request rejected successfully." - ).get_success_response() - + circle = LearningCircle.objects.get(id=circle_id) except LearningCircle.DoesNotExist: + return CustomResponse(general_message="Learning Circle not found").get_failure_response() + + if not _is_lead_or_creator(circle, user_id): return CustomResponse( - general_message="Learning Circle not found" + general_message="Only the circle lead or creator can send invitations" + ).get_failure_response() + + from .learningcircle_serializer import CircleInviteSerializer + serializer = CircleInviteSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + general_message="Invalid data", response=serializer.errors ).get_failure_response() - except Exception as e: + + target_user_id = serializer.validated_data['user_id'] + invite_as_lead = serializer.validated_data.get('lead', False) + + # Check already a member + already_member = UserCircleLink.objects.filter( + circle=circle, user_id=target_user_id, accepted=True + ).exists() + if already_member: return CustomResponse( - general_message=f"An error occurred: {str(e)}" + general_message="User is already a member of this circle" ).get_failure_response() - -class LearningCircleCreateMeetingView(APIView): - """ - API to create an online and offline meeting for a learning circle. - Only admins or leaders of the learning circle can create meetings. - """ + + # Check pending invite already exists + pending_invite = UserCircleLink.objects.filter( + circle=circle, user_id=target_user_id, is_invited=True, accepted__isnull=True + ).exists() + if pending_invite: + return CustomResponse( + general_message="An invitation is already pending for this user" + ).get_failure_response() + + UserCircleLink.objects.create( + id=str(uuid.uuid4()), + user_id=target_user_id, + circle=circle, + lead=invite_as_lead, + is_invited=True, + invited_by_id=user_id, + accepted=None, + ) + return CustomResponse(general_message="Invitation sent successfully").get_success_response() + + +class CircleInviteStatusAPI(APIView): permission_classes = [CustomizePermission] - def post(self, request, circle_id): + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve Circle Invite Status.", + responses={200: CircleInviteStatusSerializer}, + ) + def get(self, request): + """List all pending invitations for the current user.""" + user_id = JWTUtils.fetch_user_id(request) + pending = UserCircleLink.objects.filter( + user_id=user_id, is_invited=True, accepted__isnull=True + ).select_related('circle__ig', 'invited_by') + from .learningcircle_serializer import CircleInviteStatusSerializer + serializer = CircleInviteStatusSerializer(pending, many=True) + return CustomResponse( + general_message="Invitations fetched successfully", + response=serializer.data, + ).get_success_response() + + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Circle Invite Status.", + responses={200: CircleInviteStatusSerializer}, + ) + def post(self, request, link_id: str): + """Accept or reject an invitation by its link_id.""" + user_id = JWTUtils.fetch_user_id(request) + try: - # Fetch the authenticated user's ID with error handling - try: - user_id = JWTUtils.fetch_user_id(request) - if not user_id: - return CustomResponse( - general_message="Authentication failed. User ID not found." - ).get_failure_response() - except Exception as jwt_error: - return CustomResponse( - general_message="Authentication failed. Invalid token." - ).get_failure_response() + link = UserCircleLink.objects.get(id=link_id, user_id=user_id, is_invited=True) + except UserCircleLink.DoesNotExist: + return CustomResponse( + general_message="Invitation not found" + ).get_failure_response() - # Fetch the learning circle with better error handling - try: - learning_circle = LearningCircle.objects.get(id=circle_id) - except LearningCircle.DoesNotExist: - return CustomResponse( - general_message="Learning Circle not found." - ).get_failure_response() + if link.accepted is not None: + return CustomResponse( + general_message="This invitation has already been responded to" + ).get_failure_response() - # Check if the user is an admin or leader of the circle - try: - is_leader = UserCircleLink.objects.filter( - circle_id=circle_id, - user_id=user_id, - lead=True, - accepted=True - ).exists() - except Exception as db_error: - return CustomResponse( - general_message="Error checking user permissions." - ).get_failure_response() + action = request.data.get("action") + if action not in ("accept", "reject"): + return CustomResponse( + general_message="Invalid action. Must be 'accept' or 'reject'" + ).get_failure_response() - if not is_leader: - return CustomResponse( - general_message="You do not have permission to create a meeting in this circle." - ).get_failure_response() + if action == "accept": + link.accepted = True + link.accepted_at = DateTimeUtils.get_current_utc_time() + link.save() + return CustomResponse( + general_message="Invitation accepted successfully" + ).get_success_response() - # Extract data from the request with safe access - title = request.data.get("title", "") if hasattr(request, 'data') and request.data else "" - description = request.data.get("description", "") if hasattr(request, 'data') and request.data else "" - meet_time = request.data.get("meet_time") if hasattr(request, 'data') and request.data else None - duration = request.data.get("duration") if hasattr(request, 'data') and request.data else None - meet_link = request.data.get("meet_link") if hasattr(request, 'data') and request.data else None - coord_x = request.data.get("coord_x") if hasattr(request, 'data') and request.data else None - coord_y = request.data.get("coord_y") if hasattr(request, 'data') and request.data else None - meet_place = request.data.get("meet_place", "") if hasattr(request, 'data') and request.data else "" - mode = request.data.get("mode", "online") if hasattr(request, 'data') and request.data else "online" - - # Basic validation moved to view level for clearer error messages - if mode == "online": - if not meet_link: - return CustomResponse( - general_message="Meeting creation failed. 'meet_link' is required for online meetings." - ).get_failure_response() - if not meet_place: - return CustomResponse( - general_message="Meeting creation failed. 'meet_place' is required for online meetings." - ).get_failure_response() - # For online meetings, clear offline-specific fields - coord_x = None - coord_y = None - - elif mode == "offline": - if not coord_x or not coord_y or not meet_place: - return CustomResponse( - general_message="Meeting creation failed. 'coord_x', 'coord_y', and 'meet_place' are required for offline meetings." - ).get_failure_response() - # For offline meetings, clear online-specific fields - meet_link = None - - else: - return CustomResponse( - general_message="Meeting creation failed. Invalid meeting mode. Must be 'online' or 'offline'." - ).get_failure_response() + link.accepted = False + link.save() + return CustomResponse( + general_message="Invitation rejected successfully" + ).get_success_response() - # Generate a unique meeting code with error handling - try: - meet_code = generate_code() - if not meet_code: - raise ValueError("Failed to generate meeting code") - except Exception as code_error: - return CustomResponse( - general_message="Failed to generate meeting code." - ).get_failure_response() - # Prepare data for serializer - only include relevant fields - meeting_data = { - "title": title, - "description": description, - "meet_time": meet_time, - "duration": duration, - "mode": mode, - "circle_id": circle_id, - "created_by": user_id, - "meet_code": meet_code, - "meet_place": meet_place, - } +class CircleSentInvitesAPI(APIView): + permission_classes = [CustomizePermission] - # Add mode-specific fields - if mode == "online": - meeting_data["meet_link"] = meet_link - elif mode == "offline": - meeting_data["coord_x"] = coord_x - meeting_data["coord_y"] = coord_y - - # Use serializer to validate and create the meeting - try: - serializer = CircleMeetingLogCreateEditSerializer( - data=meeting_data, - context={'user_id': user_id, 'meet_code': meet_code} - ) - if not serializer.is_valid(): - return CustomResponse( - general_message="Meeting creation failed", - response=serializer.errors - ).get_failure_response() - - # Save the meeting - meeting = serializer.save() - - return CustomResponse( - general_message="Meeting created successfully", - response={ - "meet_code": meet_code, - "meeting_id": meeting.id if hasattr(meeting, 'id') else None - } - ).get_success_response() + @extend_schema( + tags=['Dashboard - Learningcircle'], + description="Retrieve Circle Sent Invites.", + responses={200: CircleSentInvitesSerializer}, + ) + def get(self, request, circle_id: str): + user_id = JWTUtils.fetch_user_id(request) - except Exception as serializer_error: - return CustomResponse( - general_message="Failed to create meeting due to data validation error." - ).get_failure_response() + try: + circle = LearningCircle.objects.get(id=circle_id) + except LearningCircle.DoesNotExist: + return CustomResponse(general_message="Learning Circle not found").get_failure_response() + + if not _is_lead_or_creator(circle, user_id): + return CustomResponse( + general_message="Only the circle lead or creator can view sent invitations" + ).get_failure_response() + + # Optional status filter: pending | accepted | rejected + status_filter = request.query_params.get("status", None) + qs = UserCircleLink.objects.filter(circle=circle, is_invited=True).select_related('user') + + if status_filter == "pending": + qs = qs.filter(accepted__isnull=True) + elif status_filter == "accepted": + qs = qs.filter(accepted=True) + elif status_filter == "rejected": + qs = qs.filter(accepted=False) + + from .learningcircle_serializer import CircleSentInvitesSerializer + serializer = CircleSentInvitesSerializer(qs, many=True) + return CustomResponse( + general_message="Sent invitations fetched successfully", + response=serializer.data, + ).get_success_response() + + +class CircleTransferLeadAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema(tags=['Dashboard - Learningcircle'], description="Create Circle Transfer Lead.", + responses={200: OpenApiResponse(description="Circle lead transferred successfully. No response body.")}, + ) + def post(self, request, circle_id: str): + user_id = JWTUtils.fetch_user_id(request) + + try: + circle = LearningCircle.objects.get(id=circle_id) + except LearningCircle.DoesNotExist: + return CustomResponse(general_message="Learning Circle not found").get_failure_response() + + if not _is_lead_or_creator(circle, user_id): + return CustomResponse( + general_message="Only the circle lead or creator can transfer leadership" + ).get_failure_response() + + # caller_link may be None if the caller is the circle creator without a UserCircleLink row + caller_link = UserCircleLink.objects.filter( + circle=circle, user_id=user_id, accepted=True, lead=True + ).first() + + target_muid = request.data.get("muid") + if not target_muid: + return CustomResponse(general_message="muid is required").get_failure_response() + + try: + target_user = User.objects.get(muid=target_muid) + except User.DoesNotExist: + return CustomResponse(general_message="No user found with this muid").get_failure_response() + + target_user_id = target_user.id - except Exception as e: - # Log the actual error for debugging - import logging - logger = logging.getLogger(__name__) - logger.error(f"Unexpected error in LearningCircleCreateMeetingView: {str(e)}") + if target_user_id == user_id: + return CustomResponse( + general_message="You are already the lead" + ).get_failure_response() + + try: + target_link = UserCircleLink.objects.get( + circle=circle, user_id=target_user_id, accepted=True + ) + except UserCircleLink.DoesNotExist: + return CustomResponse( + general_message="Target user is not an accepted member of this circle" + ).get_failure_response() + if target_link.lead: return CustomResponse( - general_message="An unexpected error occurred while creating the meeting." + general_message="Target user is already the lead" ).get_failure_response() + + # Demote the caller only if they have an explicit lead link (creator may not) + if caller_link: + caller_link.lead = False + caller_link.save() + + target_link.lead = True + target_link.save() + + return CustomResponse( + general_message="Lead transferred successfully" + ).get_success_response() diff --git a/api/dashboard/learningcircle/services.py b/api/dashboard/learningcircle/services.py new file mode 100644 index 000000000..63827df87 --- /dev/null +++ b/api/dashboard/learningcircle/services.py @@ -0,0 +1,23 @@ +from django.db.models import Count, Q, Max +from db.learning_circle import LearningCircle, UserCircleLink + +def get_campus_learning_circles(org_id: str): + """ + Returns all Learning Circles for a specific campus. + Enforces tenant isolation by strictly filtering LearningCircle by org_id. + """ + return LearningCircle.objects.filter(org_id=org_id).annotate( + member_count=Count('user_circle_link_circle__user', distinct=True, filter=Q(user_circle_link_circle__accepted=True)), + meeting_count=Count('circle_meeting_log_circle_id'), + last_meeting_time=Max('circle_meeting_log_circle_id__meet_time') + ) + +def get_top_learning_circles(org_id: str): + return get_campus_learning_circles(org_id).order_by('-member_count') + +def get_lc_members(circle_id: str, org_id: str): + return UserCircleLink.objects.filter( + circle_id=circle_id, + circle__org_id=org_id, + accepted=True + ).select_related('user', 'user__wallet_user', 'user__user_lvl_link_user__level') diff --git a/api/dashboard/learningcircle/urls.py b/api/dashboard/learningcircle/urls.py index 19e3bd4c7..4c3b58395 100644 --- a/api/dashboard/learningcircle/urls.py +++ b/api/dashboard/learningcircle/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path from . import learningcircle_views @@ -6,9 +6,10 @@ path("create/", learningcircle_views.LearningCircleView.as_view()), path("list/", learningcircle_views.LearningCircleView.as_view()), path("info//", learningcircle_views.LearningCircleView.as_view()), + path("members//",learningcircle_views.LearningCircleMemberDetailsView.as_view()), path("edit//", learningcircle_views.LearningCircleView.as_view()), path("delete//", learningcircle_views.LearningCircleView.as_view()), - path("meeting/create/", learningcircle_views.LearningCircleMeetingView.as_view()), + path("meeting/create//", learningcircle_views.LearningCircleMeetingView.as_view()), path("meeting/list-public/", learningcircle_views.LearningCircleMeetingPublicListView.as_view()), path("meeting/list/", learningcircle_views.LearningCircleMeetingListAPI.as_view()), path( @@ -48,19 +49,17 @@ learningcircle_views.LearningCircleReportAPI.as_view(), ), path( - "details//", - learningcircle_views.LearningCircleBasicDetailsView.as_view(), + "meeting/report/export//", + learningcircle_views.LearningCircleReportExportAPI.as_view(), ), - path( - "members//", - learningcircle_views.LearningCircleMemberDetailsView.as_view(), - ), - path( - "/manage_requests/", - learningcircle_views.LearningCircleManageRequestsView.as_view(), - ), - path( - "meeting/create//", - learningcircle_views.LearningCircleCreateMeetingView.as_view(), - ) + # New Endpoints + path("user-circles/", learningcircle_views.UserCircleListAPI.as_view()), + path("join//", learningcircle_views.CircleJoinAPI.as_view()), + path("members/add//", learningcircle_views.CircleMemberAddAPI.as_view()), + # Specific invite routes MUST come before the generic invite// to avoid swallowing + path("invite/status/", learningcircle_views.CircleInviteStatusAPI.as_view()), + path("invite/status//", learningcircle_views.CircleInviteStatusAPI.as_view()), + path("invite/sent//", learningcircle_views.CircleSentInvitesAPI.as_view()), + path("invite//", learningcircle_views.CircleInviteAPI.as_view()), + path("transfer-lead//", learningcircle_views.CircleTransferLeadAPI.as_view()), ] diff --git a/api/dashboard/location/location_views.py b/api/dashboard/location/location_views.py index e39cd9cc0..1727228cb 100644 --- a/api/dashboard/location/location_views.py +++ b/api/dashboard/location/location_views.py @@ -8,12 +8,19 @@ from utils.utils import CommonUtils from . import location_serializer +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s class CountryDataAPI(APIView): permission_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Retrieve Country Data.", + responses={200: location_serializer.LocationSerializer}, + ) def get(self, request, country_id=None): if country_id: countries = Country.objects.filter(id=country_id) @@ -46,6 +53,12 @@ def get(self, request, country_id=None): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Create Country Data.", + request=location_serializer.CountryCreateEditSerializer, + responses={200: location_serializer.LocationSerializer}, + ) def post(self, request): request_data = request.data request_data["created_by"] = request_data[ @@ -61,6 +74,11 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Partially update Country Data.", + responses={200: location_serializer.CountryCreateEditSerializer}, + ) def patch(self, request, country_id): country = Country.objects.get(id=country_id) request_data = request.data @@ -77,6 +95,9 @@ def patch(self, request, country_id): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Location'], description="Delete Country Data.", + responses={200: location_serializer.LocationSerializer}, + ) def delete(self, request, country_id): country = Country.objects.get(id=country_id) country.delete() @@ -90,6 +111,11 @@ class StateDataAPI(APIView): permission_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Retrieve State Data.", + responses={200: location_serializer.StateRetrievalSerializer}, + ) def get(self, request, state_id=None): if state_id: states = State.objects.filter(pk=state_id).select_related( @@ -123,6 +149,12 @@ def get(self, request, state_id=None): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Create State Data.", + request=location_serializer.StateCreateEditSerializer, + responses={200: location_serializer.StateRetrievalSerializer}, + ) def post(self, request): request_data = request.data request_data["created_by"] = request_data[ @@ -141,6 +173,11 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Partially update State Data.", + responses={200: location_serializer.StateCreateEditSerializer}, + ) def patch(self, request, state_id): state = State.objects.get(id=state_id) request_data = request.data @@ -157,6 +194,9 @@ def patch(self, request, state_id): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Location'], description="Delete State Data.", + responses={200: location_serializer.StateRetrievalSerializer}, + ) def delete(self, request, state_id): state = State.objects.get(id=state_id) state.delete() @@ -170,6 +210,11 @@ class ZoneDataAPI(APIView): permission_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Retrieve Zone Data.", + responses={200: location_serializer.ZoneRetrievalSerializer}, + ) def get(self, request, zone_id=None): if zone_id: zones = Zone.objects.filter(pk=zone_id) @@ -208,6 +253,12 @@ def get(self, request, zone_id=None): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Create Zone Data.", + request=location_serializer.ZoneCreateEditSerializer, + responses={200: location_serializer.ZoneRetrievalSerializer}, + ) def post(self, request): request_data = request.data request_data["created_by"] = request_data[ @@ -225,6 +276,11 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Partially update Zone Data.", + responses={200: location_serializer.ZoneCreateEditSerializer}, + ) def patch(self, request, zone_id): zone = Zone.objects.get(id=zone_id) request_data = request.data @@ -241,6 +297,9 @@ def patch(self, request, zone_id): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Location'], description="Delete Zone Data.", + responses={200: location_serializer.ZoneRetrievalSerializer}, + ) def delete(self, request, zone_id): zone = Zone.objects.get(id=zone_id) zone.delete() @@ -254,6 +313,11 @@ class DistrictDataAPI(APIView): permission_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Retrieve District Data.", + responses={200: location_serializer.DistrictRetrievalSerializer}, + ) def get(self, request, district_id=None): if district_id: districts = District.objects.filter(pk=district_id) @@ -298,6 +362,12 @@ def get(self, request, district_id=None): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Create District Data.", + request=location_serializer.DistrictCreateEditSerializer, + responses={200: location_serializer.DistrictRetrievalSerializer}, + ) def post(self, request): request_data = request.data request_data["created_by"] = request_data[ @@ -315,6 +385,11 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Location'], + description="Partially update District Data.", + responses={200: location_serializer.DistrictCreateEditSerializer}, + ) def patch(self, request, district_id): district = District.objects.get(id=district_id) request_data = request.data @@ -331,6 +406,9 @@ def patch(self, request, district_id): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Location'], description="Delete District Data.", + responses={200: location_serializer.DistrictRetrievalSerializer}, + ) def delete(self, request, district_id): district = District.objects.get(id=district_id) district.delete() @@ -341,6 +419,16 @@ def delete(self, request, district_id): class CountryListApi(APIView): + @extend_schema(tags=['Dashboard - Location'], description="Retrieve Country List Api.", + responses={200: inline_serializer( + name='LocationCountryListResponse', + fields={ + 'id': s.CharField(), + 'name': s.CharField(), + }, + many=True, + )}, + ) def get(self, request): country = Country.objects.all().values("id", "name").order_by("name") @@ -348,6 +436,16 @@ def get(self, request): class StateListApi(APIView): + @extend_schema(tags=['Dashboard - Location'], description="Retrieve State List Api.", + responses={200: inline_serializer( + name='LocationStateListResponse', + fields={ + 'id': s.CharField(), + 'name': s.CharField(), + }, + many=True, + )}, + ) def get(self, request): state = State.objects.all().values("id", "name").order_by("name") @@ -355,6 +453,16 @@ def get(self, request): class ZoneListApi(APIView): + @extend_schema(tags=['Dashboard - Location'], description="Retrieve Zone List Api.", + responses={200: inline_serializer( + name='LocationZoneListResponse', + fields={ + 'id': s.CharField(), + 'name': s.CharField(), + }, + many=True, + )}, + ) def get(self, request): zone = Zone.objects.all().values("id", "name").order_by("name") diff --git a/api/dashboard/manage_interns/interns_views.py b/api/dashboard/manage_interns/interns_views.py new file mode 100644 index 000000000..29a287996 --- /dev/null +++ b/api/dashboard/manage_interns/interns_views.py @@ -0,0 +1,385 @@ +import uuid +from io import BytesIO + +import openpyxl +from django.db import transaction +from django.db.models import Count +from django.http import FileResponse +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.views import APIView +from drf_spectacular.utils import OpenApiResponse, extend_schema + +from db.intern import UserInternGuildLink +from db.user import User, UserRoleLink, Role +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternGuildStatus +from utils.utils import CommonUtils + +from .serializers import ManageInternSerializer + +class ManageInternAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve intern(s) or intern detail.", + responses={200: ManageInternSerializer(many=True)}, + ) + def get(self, request, intern_id=None): + if intern_id: + intern = UserInternGuildLink.objects.select_related('user').filter(id=intern_id).first() + if not intern: + return CustomResponse(general_message="Intern not found.").get_failure_response() + serializer = ManageInternSerializer(intern) + return CustomResponse(response=serializer.data).get_success_response() + + interns = UserInternGuildLink.objects.select_related('user').prefetch_related( + 'user__user_role_link_user__role' + ).order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + interns, request, + ['user__full_name', 'guild', 'status'], + {'created_at': 'created_at', 'status': 'status'} + ) + + serializer = ManageInternSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Onboard a new intern.", + responses={200: OpenApiResponse(description="Intern onboarded successfully.")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + serializer = ManageInternSerializer(data=request.data, context={'user_id': user_id}) + + if serializer.is_valid(): + serializer.save() + return CustomResponse(general_message="Intern onboarded successfully.").get_success_response() + + return CustomResponse(response=serializer.errors).get_failure_response() + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Update intern details (partial update).", + responses={200: OpenApiResponse(description="Intern details updated successfully.")}, + ) + def patch(self, request, intern_id): + user_id = JWTUtils.fetch_user_id(request) + intern = UserInternGuildLink.objects.filter(id=intern_id).first() + if not intern: + return CustomResponse(general_message="Intern not found.").get_failure_response() + + old_data = { + "guild": intern.guild, + "status": intern.status + } + + serializer = ManageInternSerializer(intern, data=request.data, partial=True, context={'user_id': user_id}) + + if serializer.is_valid(): + serializer.save() + + new_guild = request.data.get("guild") + if new_guild and new_guild != old_data["guild"]: + from db.mentor import SystemActionLog + SystemActionLog.objects.create( + action_type=SystemActionLog.ActionType.INTERN_GUILD_REASSIGN.value, + actor_user_id=user_id, + subject_user_id=intern.user_id, + entity_name='user_intern_guild_link', + entity_id=intern.id, + old_data=old_data, + new_data={"guild": new_guild, "status": intern.status} + ) + + return CustomResponse(general_message="Intern details updated successfully.").get_success_response() + + return CustomResponse(response=serializer.errors).get_failure_response() + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Deactivate (soft-delete) an intern.", + responses={200: OpenApiResponse(description="Intern deactivated successfully.")}, + ) + def delete(self, request, intern_id): + from django.db import transaction + from db.user import UserRoleLink + from utils.types import RoleType + + user_id = JWTUtils.fetch_user_id(request) + intern = UserInternGuildLink.objects.filter(id=intern_id).first() + if not intern: + return CustomResponse(general_message="Intern not found.").get_failure_response() + + with transaction.atomic(): + # Soft delete / Deactivate + intern.status = InternGuildStatus.INACTIVE.value + intern.updated_by_id = user_id + intern.save() + + # Remove "Intern" or "Intern Lead" role + UserRoleLink.objects.filter( + user=intern.user, + role__title__in=[RoleType.INTERN.value, RoleType.INTERN_LEAD.value] + ).delete() + + return CustomResponse(general_message="Intern deactivated successfully.").get_success_response() + +class ManageInternStatusAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve intern status statistics.", + responses={200: OpenApiResponse(description="Intern status counts.")}, + ) + def get(self, request): + stats = UserInternGuildLink.objects.values('status').annotate(count=Count('id')) + + data = { + InternGuildStatus.ACTIVE.value: 0, + InternGuildStatus.AT_RISK.value: 0, + InternGuildStatus.ON_LEAVE.value: 0, + InternGuildStatus.INACTIVE.value: 0, + } + + for stat in stats: + data[stat['status']] = stat['count'] + + data['total_interns'] = UserInternGuildLink.objects.count() + + return CustomResponse(response=data).get_success_response() + +class ManageInternExportAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Export interns list as CSV.", + responses={200: OpenApiResponse(description="CSV attachment of interns.")}, + ) + def get(self, request): + interns = UserInternGuildLink.objects.all().select_related('user') + + # We can reuse the CommonUtils method to generate a CSV response if it exists, + # but otherwise we can manually build it or use a library. + # We'll just build a basic CSV string and return it in CustomResponse or as HttpResponse. + from django.http import HttpResponse + import csv + + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = 'attachment; filename="interns.csv"' + + writer = csv.writer(response) + writer.writerow(['Full Name', 'MuID', 'Guild', 'Status', 'Created At']) + + for intern in interns: + writer.writerow([ + intern.user.full_name, + intern.user.muid, + intern.guild, + intern.status, + intern.created_at.strftime('%Y-%m-%d %H:%M:%S') + ]) + + return response + + +class ManageInternBulkImportTemplateAPIView(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Download the Excel template for bulk intern import. Columns: muid, guild.", + responses={200: OpenApiResponse(description="Excel file download.")}, + ) + def get(self, request): + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Interns" + ws.append(['muid', 'guild']) + + output = BytesIO() + wb.save(output) + output.seek(0) + + response = FileResponse( + output, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="intern_bulk_import_template.xlsx"' + return response + + +class ManageInternBulkImportAPI(APIView): + authentication_classes = [CustomizePermission] + parser_classes = [MultiPartParser, FormParser] + + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description=( + "Bulk assign users as interns by uploading an Excel (.xlsx) file. " + "Required columns: 'muid', 'guild'. Role defaults to 'Intern'. " + "Rows where the user is already an intern are skipped." + ), + responses={200: OpenApiResponse(description="Bulk import result.")}, + ) + def post(self, request): + actor_user_id = JWTUtils.fetch_user_id(request) + + excel_file = request.FILES.get('excel_file') + if not excel_file: + return CustomResponse( + general_message="No file uploaded. Please attach an Excel file with key 'excel_file'." + ).get_failure_response() + + # Validate file extension + if not excel_file.name.endswith('.xlsx'): + return CustomResponse( + general_message="Invalid file type. Only .xlsx files are supported." + ).get_failure_response() + + try: + wb = openpyxl.load_workbook(excel_file) + except Exception: + return CustomResponse( + general_message="Could not read the uploaded file. Please ensure it is a valid .xlsx file." + ).get_failure_response() + + ws = wb.active + headers = [cell.value for cell in ws[1]] if ws.max_row > 0 else [] + + # Validate required headers + required_headers = ['muid', 'guild'] + missing_headers = [h for h in required_headers if h not in headers] + if missing_headers: + return CustomResponse( + general_message=f"Missing required column(s): {', '.join(missing_headers)}. " + f"Expected headers: {required_headers}." + ).get_failure_response() + + muid_idx = headers.index('muid') + guild_idx = headers.index('guild') + + success_count = 0 + failed_rows = [] + + # Fetch the Intern role once + intern_role = Role.objects.filter(title=RoleType.INTERN.value).first() + if not intern_role: + return CustomResponse( + general_message=f"Role '{RoleType.INTERN.value}' does not exist in the system. " + f"Please contact a system administrator." + ).get_failure_response() + + # Track muids processed in this file to handle in-file duplicates + seen_muids = set() + + for row_num, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2): + muid = row[muid_idx] + guild = row[guild_idx] + + # Skip entirely empty rows + if muid is None and guild is None: + continue + + # Coerce to string and strip whitespace if present + muid = str(muid).strip() if muid is not None else None + guild = str(guild).strip() if guild is not None else None + + # --- Edge case: missing muid or guild value --- + if not muid or not guild: + failed_rows.append({ + "row": row_num, + "muid": muid or "(empty)", + "reason": "Both 'muid' and 'guild' values are required." + }) + continue + + # --- Edge case: duplicate muid within the same file --- + if muid in seen_muids: + failed_rows.append({ + "row": row_num, + "muid": muid, + "reason": "Duplicate muid in file — already processed earlier in this upload." + }) + continue + + seen_muids.add(muid) + + try: + with transaction.atomic(): + # --- Edge case: user not found --- + user = User.objects.filter(muid=muid).first() + if not user: + failed_rows.append({ + "row": row_num, + "muid": muid, + "reason": "No user found with this muid." + }) + continue + + # --- Edge case: user is already an intern --- + if UserInternGuildLink.objects.filter(user=user).exists(): + failed_rows.append({ + "row": row_num, + "muid": muid, + "reason": "User is already onboarded as an intern." + }) + continue + + # Create the intern guild link + UserInternGuildLink.objects.create( + id=str(uuid.uuid4()), + user=user, + guild=guild, + status=InternGuildStatus.ACTIVE.value, + created_by_id=actor_user_id, + updated_by_id=actor_user_id, + ) + + # Assign 'Intern' role (idempotent via get_or_create) + UserRoleLink.objects.get_or_create( + user=user, + role=intern_role, + defaults={ + 'verified': True, + 'is_active': True, + 'created_by_id': actor_user_id + } + ) + + success_count += 1 + + except Exception as e: + failed_rows.append({ + "row": row_num, + "muid": muid, + "reason": f"Unexpected error: {str(e)}" + }) + + return CustomResponse( + general_message="Bulk intern import completed.", + response={ + "success_count": success_count, + "failed_count": len(failed_rows), + "failed_rows": failed_rows, + } + ).get_success_response() diff --git a/api/dashboard/manage_interns/leave/leave_views.py b/api/dashboard/manage_interns/leave/leave_views.py new file mode 100644 index 000000000..e62e581ae --- /dev/null +++ b/api/dashboard/manage_interns/leave/leave_views.py @@ -0,0 +1,99 @@ +from django.utils.timezone import now +from rest_framework.views import APIView +from django.db import transaction +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternLeaveStatus, InternGuildStatus +from utils.utils import CommonUtils +from db.intern import InternLeaveRequest, UserInternGuildLink + +from .serializers import ManageInternLeaveSerializer + +class ManageInternLeaveAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="List all intern leave requests, or retrieve a single leave request by ID.", + responses={200: ManageInternLeaveSerializer}, + ) + def get(self, request, leave_id=None): + if leave_id: + leave = InternLeaveRequest.objects.filter(id=leave_id).first() + if not leave: + return CustomResponse(general_message="Leave request not found.").get_failure_response() + serializer = ManageInternLeaveSerializer(leave) + return CustomResponse(response=serializer.data).get_success_response() + + leaves = InternLeaveRequest.objects.select_related('user').all().order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + leaves, request, + ['user__full_name', 'leave_type', 'status'], + {'created_at': 'created_at', 'status': 'status'} + ) + + serializer = ManageInternLeaveSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + +class ManageInternLeaveReviewAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Review (approve/reject) an intern leave request.", + responses={200: OpenApiResponse(description="Leave request reviewed successfully.")}, + ) + def patch(self, request, leave_id): + admin_id = JWTUtils.fetch_user_id(request) + action = request.data.get("action") + review_note = request.data.get("review_note") + + if action not in ["approve", "reject"]: + return CustomResponse(general_message="Invalid action. Must be 'approve' or 'reject'.").get_failure_response() + + leave = InternLeaveRequest.objects.filter(id=leave_id, status=InternLeaveStatus.PENDING.value).first() + if not leave: + return CustomResponse(general_message="Pending leave request not found.").get_failure_response() + + with transaction.atomic(): + if action == "approve": + leave.status = InternLeaveStatus.APPROVED.value + + today = now().date() + if leave.start_date <= today <= leave.end_date: + guild_link = UserInternGuildLink.objects.filter(user_id=leave.user_id).first() + if guild_link and guild_link.status != InternGuildStatus.ON_LEAVE.value: + guild_link.previous_status = guild_link.status + guild_link.status = InternGuildStatus.ON_LEAVE.value + guild_link.updated_by_id = admin_id + guild_link.save(update_fields=['status', 'previous_status', 'updated_by_id']) + + elif action == "reject": + leave.status = InternLeaveStatus.REJECTED.value + + leave.reviewed_by_id = admin_id + leave.reviewed_at = now() + leave.review_note = review_note + leave.save() + + from db.mentor import SystemActionLog + SystemActionLog.objects.create( + action_type=SystemActionLog.ActionType.INTERN_LEAVE_REVIEW.value, + actor_user_id=admin_id, + subject_user_id=leave.user_id, + entity_name='intern_leave_request', + entity_id=leave.id, + new_data={"action": action, "review_note": review_note} + ) + + return CustomResponse(general_message=f"Leave request {action}ed successfully.").get_success_response() diff --git a/api/dashboard/manage_interns/leave/serializers.py b/api/dashboard/manage_interns/leave/serializers.py new file mode 100644 index 000000000..15d5cb6be --- /dev/null +++ b/api/dashboard/manage_interns/leave/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers +from db.intern import InternLeaveRequest + +class ManageInternLeaveSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.full_name', read_only=True) + user_muid = serializers.CharField(source='user.muid', read_only=True) + + class Meta: + model = InternLeaveRequest + fields = [ + 'id', 'user', 'user_name', 'user_muid', 'leave_type', 'start_date', 'end_date', 'reason', + 'status', 'review_note', 'created_at' + ] + diff --git a/api/dashboard/manage_interns/leave/urls.py b/api/dashboard/manage_interns/leave/urls.py new file mode 100644 index 000000000..865b7377a --- /dev/null +++ b/api/dashboard/manage_interns/leave/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import leave_views + +urlpatterns = [ + path("", leave_views.ManageInternLeaveAPI.as_view(), name="manage-intern-leave-list"), + path("/", leave_views.ManageInternLeaveAPI.as_view(), name="manage-intern-leave-detail"), + path("/review/", leave_views.ManageInternLeaveReviewAPI.as_view(), name="manage-intern-leave-review"), +] diff --git a/api/dashboard/manage_interns/reviews/review_views.py b/api/dashboard/manage_interns/reviews/review_views.py new file mode 100644 index 000000000..fbf38bd0d --- /dev/null +++ b/api/dashboard/manage_interns/reviews/review_views.py @@ -0,0 +1,465 @@ +from datetime import timedelta +import uuid + +from django.db import transaction +from django.utils.timezone import now +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternSubmissionStatus, InternGuildStatus, InternHashtag +from db.intern import InternDailyTimesheet, UserInternGuildLink, InternWeeklyReview +from db.task import KarmaActivityLog, TaskList, Wallet +from db.achievement import UserStreak +from utils.utils import CommonUtils +from .serializers import ManageInternWeeklyReviewSerializer, ManageInternTimesheetSerializer + + +# ============================================================================ +# Streak Recalculation Helpers +# ============================================================================ + +def recalculate_intern_timesheet_streak(user_id: str) -> dict: + """ + Recalculates the intern_timesheet streak from scratch by looking at the + entire history of approved timesheets, sorted chronologically. + + This is order-independent: the result is identical regardless of the + order the admin approved individual timesheets. + + Returns: + dict with keys: + - current_streak (int): The streak ending at the latest entry_date + - longest_streak (int): The overall longest continuous streak + - last_active (date | None): The most recent approved entry_date + - milestone_hits (dict): {milestone_days: count_of_times_reached} + """ + approved_dates = list( + InternDailyTimesheet.objects.filter( + user_id=user_id, + status=InternSubmissionStatus.APPROVED.value, + ) + .order_by('entry_date') + .values_list('entry_date', flat=True) + .distinct() # Guard against duplicate approvals for the same date + ) + + if not approved_dates: + return { + 'current_streak': 0, + 'longest_streak': 0, + 'last_active': None, + 'milestone_hits': {}, + } + + milestones = [7, 14, 30, 60, 90] + milestone_hits = {m: 0 for m in milestones} + + current = 1 + longest = 1 + for m in milestones: + if current >= m: + milestone_hits[m] += 1 + + for i in range(1, len(approved_dates)): + prev = approved_dates[i - 1] + curr = approved_dates[i] + diff = (curr - prev).days + + # Consecutive: next calendar day, OR Friday→Monday (skipping weekend) + is_consecutive = ( + diff == 1 or + (diff == 3 and curr.weekday() == 0 and prev.weekday() == 4) + ) + + if is_consecutive: + current += 1 + elif diff > 0: + # Gap — streak resets + current = 1 + # diff == 0 means duplicate date (already deduped, but just in case) + + longest = max(longest, current) + for m in milestones: + if current == m: + milestone_hits[m] += 1 + + return { + 'current_streak': current, + 'longest_streak': longest, + 'last_active': approved_dates[-1], + 'milestone_hits': milestone_hits, + } + + +def recalculate_intern_weekly_streak(user_id: str) -> dict: + """ + Recalculates the intern_weekly_review streak from scratch by looking at + the entire history of approved weekly reviews, sorted chronologically. + + A late review (is_late=True) resets the streak to 0. + + Returns: + dict with keys: + - current_streak (int) + - longest_streak (int) + - last_active (date | None): The most recent approved week_start_date + """ + approved_reviews = list( + InternWeeklyReview.objects.filter( + user_id=user_id, + status=InternSubmissionStatus.APPROVED.value, + ) + .order_by('week_start_date') + .values('week_start_date', 'is_late') + ) + + if not approved_reviews: + return {'current_streak': 0, 'longest_streak': 0, 'last_active': None} + + current = 0 + longest = 0 + last_week_start = None + + for review in approved_reviews: + week_start = review['week_start_date'] + is_late = review['is_late'] + + if is_late: + current = 0 + elif last_week_start is None: + current = 1 + elif (week_start - last_week_start).days == 7: + current += 1 + elif week_start == last_week_start: + pass # Duplicate, ignore + else: + current = 1 # Gap + + longest = max(longest, current) + last_week_start = week_start + + return { + 'current_streak': current, + 'longest_streak': longest, + 'last_active': last_week_start, + } + + +def _award_missing_milestone_karma(user_id: str, admin_id: str, milestone_hits: dict, milestone_map: dict): + """ + Awards milestone karma for any gaps between expected and actual hits. + + Uses "Expected vs Actual" logic: + - expected_hits: how many times the streak crossed a milestone (from recalculation) + - actual_hits: how many times we already gave karma for that milestone + + Only awards the *difference*, so this is idempotent and safe to call on + every approval regardless of order. + + Args: + user_id: The intern's user ID + admin_id: The admin performing the approval (used as created_by) + milestone_hits: {milestone_days: expected_hit_count} from recalculate_* + milestone_map: {milestone_days: (hashtag, bonus_karma)} + """ + from django.db.models import F + + for milestone_days, (hashtag, bonus_karma) in milestone_map.items(): + expected = milestone_hits.get(milestone_days, 0) + if expected == 0: + continue + + task_list = TaskList.objects.filter(hashtag=hashtag).first() + if not task_list: + continue + + actual = KarmaActivityLog.objects.filter( + user_id=user_id, + task=task_list, + appraiser_approved=True, + ).count() + + missing = expected - actual + if missing <= 0: + continue + + wallet, _ = Wallet.objects.get_or_create( + user_id=user_id, + defaults={'created_by_id': admin_id, 'updated_by_id': admin_id} + ) + + for _ in range(missing): + KarmaActivityLog.objects.create( + id=str(uuid.uuid4()), + user_id=user_id, + task=task_list, + karma=bonus_karma, + appraiser_approved=True, + updated_by_id=admin_id, + updated_at=now(), + created_by_id=admin_id, + created_at=now(), + ) + Wallet.objects.filter(id=wallet.id).update(karma=F('karma') + (bonus_karma * missing)) + + +class InternTimesheetReviewAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve a timesheet for review.", + responses={200: ManageInternTimesheetSerializer}, + ) + def get(self, request, timesheet_id): + timesheet = InternDailyTimesheet.objects.filter(id=timesheet_id).first() + if not timesheet: + return CustomResponse(general_message="Timesheet not found.").get_failure_response() + serializer = ManageInternTimesheetSerializer(timesheet) + return CustomResponse(response=serializer.data).get_success_response() + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Approve or reject a timesheet.", + responses={200: OpenApiResponse(description="Timesheet reviewed successfully.")}, + ) + def patch(self, request, timesheet_id): + admin_id = JWTUtils.fetch_user_id(request) + action = request.data.get("action") + review_note = request.data.get("review_note") + + if action not in ["approve", "reject"]: + return CustomResponse(general_message="Invalid action. Must be 'approve' or 'reject'.").get_failure_response() + + timesheet = InternDailyTimesheet.objects.filter(id=timesheet_id, status=InternSubmissionStatus.PENDING.value).first() + if not timesheet: + return CustomResponse(general_message="Pending timesheet not found.").get_failure_response() + + with transaction.atomic(): + if action == "approve": + timesheet.status = InternSubmissionStatus.APPROVED.value + timesheet.reviewed_by_id = admin_id + timesheet.reviewed_at = now() + # Save the approved status first so the recalculation includes this timesheet + timesheet.save() + + user_id = timesheet.user_id + + # --- Ground-up streak recalculation (order-independent) --- + recalc = recalculate_intern_timesheet_streak(user_id) + + streak, _ = UserStreak.objects.get_or_create(user_id=user_id, streak_type='intern_timesheet') + streak.current_streak = recalc['current_streak'] + streak.longest_streak = recalc['longest_streak'] + streak.last_active = recalc['last_active'] + streak.save() + + # --- Base karma with streak multiplier --- + base_karma = InternHashtag.DAILY_LOG_KARMA.value + multiplier = 1.0 + if streak.current_streak >= 30: + multiplier = 2.0 + elif streak.current_streak >= 14: + multiplier = 1.5 + elif streak.current_streak >= 7: + multiplier = 1.2 + + karma_to_award = int(base_karma * multiplier) + + task_list = TaskList.objects.filter(hashtag=InternHashtag.DAILY_LOG_HASHTAG.value).first() + if task_list: + KarmaActivityLog.objects.create( + id=str(uuid.uuid4()), + user_id=user_id, + task=task_list, + karma=karma_to_award, + appraiser_approved=True, + updated_by_id=admin_id, + updated_at=now(), + created_by_id=admin_id, + created_at=now(), + ) + from django.db.models import F + wallet, _ = Wallet.objects.get_or_create(user_id=user_id, defaults={'created_by_id': admin_id, 'updated_by_id': admin_id}) + Wallet.objects.filter(id=wallet.id).update(karma=F('karma') + karma_to_award) + + # --- Milestone karma (Expected vs Actual — idempotent) --- + milestone_map = { + 7: (InternHashtag.STREAK_7_HASHTAG.value, InternHashtag.STREAK_7_KARMA.value), + 14: (InternHashtag.STREAK_14_HASHTAG.value, InternHashtag.STREAK_14_KARMA.value), + 30: (InternHashtag.STREAK_30_HASHTAG.value, InternHashtag.STREAK_30_KARMA.value), + 60: (InternHashtag.STREAK_60_HASHTAG.value, InternHashtag.STREAK_60_KARMA.value), + 90: (InternHashtag.STREAK_90_HASHTAG.value, InternHashtag.STREAK_90_KARMA.value), + } + _award_missing_milestone_karma(user_id, admin_id, recalc['milestone_hits'], milestone_map) + + guild_link = UserInternGuildLink.objects.filter(user_id=user_id).first() + if guild_link and guild_link.status == InternGuildStatus.AT_RISK.value: + guild_link.status = InternGuildStatus.ACTIVE.value + guild_link.save() + + # timesheet was already saved above; return early + return CustomResponse(general_message="Timesheet approved successfully.").get_success_response() + + elif action == "reject": + timesheet.status = InternSubmissionStatus.REJECTED.value + timesheet.reviewed_by_id = admin_id + timesheet.reviewed_at = now() + timesheet.review_note = review_note + + timesheet.save() + return CustomResponse(general_message="Timesheet rejected successfully.").get_success_response() + +class InternWeeklyReviewReviewAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve a weekly review for detail.", + responses={200: ManageInternWeeklyReviewSerializer}, + ) + def get(self, request, review_id): + review = InternWeeklyReview.objects.filter(id=review_id).first() + if not review: + return CustomResponse(general_message="Weekly review not found.").get_failure_response() + serializer = ManageInternWeeklyReviewSerializer(review) + return CustomResponse(response=serializer.data).get_success_response() + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Approve or reject a weekly review.", + responses={200: OpenApiResponse(description="Weekly review reviewed successfully.")}, + ) + def patch(self, request, review_id): + admin_id = JWTUtils.fetch_user_id(request) + action = request.data.get("action") + review_note = request.data.get("review_note") + + if action not in ["approve", "reject"]: + return CustomResponse(general_message="Invalid action. Must be 'approve' or 'reject'.").get_failure_response() + + review = InternWeeklyReview.objects.filter(id=review_id, status=InternSubmissionStatus.PENDING.value).first() + if not review: + return CustomResponse(general_message="Pending weekly review not found.").get_failure_response() + + with transaction.atomic(): + if action == "approve": + review.status = InternSubmissionStatus.APPROVED.value + review.reviewed_by_id = admin_id + review.reviewed_at = now() + # Save first so the recalculation includes this review + review.save() + + user_id = review.user_id + + # --- Ground-up streak recalculation (order-independent) --- + recalc = recalculate_intern_weekly_streak(user_id) + + streak, _ = UserStreak.objects.get_or_create(user_id=user_id, streak_type='intern_weekly_review') + streak.current_streak = recalc['current_streak'] + streak.longest_streak = recalc['longest_streak'] + streak.last_active = recalc['last_active'] + streak.save() + + # --- Base karma for weekly review --- + karma_to_award = InternHashtag.WEEKLY_REVIEW_KARMA.value + + task_list = TaskList.objects.filter(hashtag=InternHashtag.WEEKLY_REVIEW_HASHTAG.value).first() + if task_list: + KarmaActivityLog.objects.create( + id=str(uuid.uuid4()), + user_id=user_id, + task=task_list, + karma=karma_to_award, + appraiser_approved=True, + updated_by_id=admin_id, + updated_at=now(), + created_by_id=admin_id, + created_at=now(), + ) + from django.db.models import F + wallet, _ = Wallet.objects.get_or_create(user_id=user_id, defaults={'created_by_id': admin_id, 'updated_by_id': admin_id}) + Wallet.objects.filter(id=wallet.id).update(karma=F('karma') + karma_to_award) + + # weekly reviews currently have no milestone map, but the + # pattern is here if needed in the future. + + # review was already saved above; skip the final save + return CustomResponse(general_message=f"Weekly review {action}ed successfully.").get_success_response() + + elif action == "reject": + review.status = InternSubmissionStatus.REJECTED.value + review.reviewed_by_id = admin_id + review.reviewed_at = now() + review.review_note = review_note + + review.save() + return CustomResponse(general_message=f"Weekly review {action}ed successfully.").get_success_response() + + +class InternWeeklyReviewListAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="List weekly reviews with filters.", + responses={200: ManageInternWeeklyReviewSerializer(many=True)}, + ) + def get(self, request): + reviews = InternWeeklyReview.objects.all().order_by('-created_at') + + status = request.query_params.get('status') + if status: + reviews = reviews.filter(status=status) + + paginated_queryset = CommonUtils.get_paginated_queryset( + reviews, request, + ['user__full_name', 'status', 'team'], + {'created_at': 'created_at', 'status': 'status'} + ) + + serializer = ManageInternWeeklyReviewSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + +class InternTimesheetListAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="List timesheets with filters.", + responses={200: ManageInternTimesheetSerializer(many=True)}, + ) + def get(self, request): + timesheets = InternDailyTimesheet.objects.all().order_by('-created_at') + + status = request.query_params.get('status') + if status: + timesheets = timesheets.filter(status=status) + + paginated_queryset = CommonUtils.get_paginated_queryset( + timesheets, request, + ['user__full_name', 'status'], + {'created_at': 'created_at', 'status': 'status'} + ) + + serializer = ManageInternTimesheetSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() diff --git a/api/dashboard/manage_interns/reviews/serializers.py b/api/dashboard/manage_interns/reviews/serializers.py new file mode 100644 index 000000000..27fe28075 --- /dev/null +++ b/api/dashboard/manage_interns/reviews/serializers.py @@ -0,0 +1,28 @@ +from rest_framework import serializers +from db.intern import InternWeeklyReview, InternDailyTimesheet + +class ManageInternWeeklyReviewSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.full_name', read_only=True) + muid = serializers.CharField(source='user.muid', read_only=True) + + class Meta: + model = InternWeeklyReview + fields = [ + 'id', 'user_id', 'user_name', 'muid', 'iso_year', 'iso_week', 'week_start_date', 'week_end_date', + 'team', 'is_on_leave', 'tasks_assigned', 'tasks_completed', + 'weekly_review', 'task_remarks', 'hours_committed', 'blockers', + 'leave_days', 'suggestions', 'is_late', 'status', + 'review_note', 'created_at' + ] + +class ManageInternTimesheetSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.full_name', read_only=True) + muid = serializers.CharField(source='user.muid', read_only=True) + + class Meta: + model = InternDailyTimesheet + fields = [ + 'id', 'user_id', 'user_name', 'muid', 'entry_date', 'task', 'description', + 'hours', 'blockers', 'end_of_day_note', 'edit_reason', 'status', + 'review_note', 'created_at' + ] diff --git a/api/dashboard/manage_interns/reviews/urls.py b/api/dashboard/manage_interns/reviews/urls.py new file mode 100644 index 000000000..3004eefc7 --- /dev/null +++ b/api/dashboard/manage_interns/reviews/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import review_views + +urlpatterns = [ + path("timesheets//review/", review_views.InternTimesheetReviewAPI.as_view(), name="intern-timesheet-review"), + path("reviews//review/", review_views.InternWeeklyReviewReviewAPI.as_view(), name="intern-weekly-review-review"), + + path("timesheets/", review_views.InternTimesheetListAPI.as_view(), name="intern-timesheet-list"), + path("", review_views.InternWeeklyReviewListAPI.as_view(), name="intern-weekly-review-list"), +] diff --git a/api/dashboard/manage_interns/serializers.py b/api/dashboard/manage_interns/serializers.py new file mode 100644 index 000000000..01867ba0f --- /dev/null +++ b/api/dashboard/manage_interns/serializers.py @@ -0,0 +1,150 @@ +from rest_framework import serializers +from django.db import transaction + +from db.intern import UserInternGuildLink +from db.user import User, UserRoleLink, Role +from utils.types import InternGuildStatus, RoleType + + +class ManageInternSerializer(serializers.ModelSerializer): + full_name = serializers.CharField(source='user.full_name', read_only=True) + muid = serializers.CharField(source='user.muid', read_only=True) + mu_id = serializers.CharField(write_only=True, required=False) + user_id = serializers.CharField(write_only=True, required=False) + role = serializers.CharField(required=False) + + class Meta: + model = UserInternGuildLink + fields = ['id', 'user', 'full_name', 'muid', 'mu_id', 'user_id', 'guild', 'status', 'role', 'created_at'] + read_only_fields = ['user'] + + def to_representation(self, instance): + data = super().to_representation(instance) + # Collect all intern-type roles the user currently holds + intern_role_titles = [RoleType.INTERN.value, RoleType.INTERN_LEAD.value] + + # Check if we have prefetched data to avoid N+1 queries + if hasattr(instance.user, '_prefetched_objects_cache') and 'user_role_link_user' in instance.user._prefetched_objects_cache: + active_roles = [ + link.role.title for link in instance.user.user_role_link_user.all() + if link.role.title in intern_role_titles + ] + else: + active_roles = list( + UserRoleLink.objects.filter( + user=instance.user, + role__title__in=intern_role_titles + ).values_list('role__title', flat=True) + ) + + data['roles'] = active_roles + # Legacy 'role' field: prefer Intern Lead if held, else Intern + data['role'] = ( + RoleType.INTERN_LEAD.value if RoleType.INTERN_LEAD.value in active_roles + else RoleType.INTERN.value + ) + return data + + def validate_role(self, value): + allowed = [RoleType.INTERN.value, RoleType.INTERN_LEAD.value] + if value not in allowed: + raise serializers.ValidationError( + f"Invalid role. Allowed values: {allowed}" + ) + return value + + def validate(self, attrs): + user_id = attrs.pop('user_id', None) + mu_id = attrs.pop('mu_id', None) + + # Only required on create + if not self.instance: + if not user_id and not mu_id: + raise serializers.ValidationError({"user": "Either user_id or mu_id is required."}) + + user = None + if mu_id: + user = User.objects.filter(muid=mu_id).first() + if not user: + raise serializers.ValidationError({"mu_id": "No user found with this mu_id."}) + elif user_id: + user = User.objects.filter(id=user_id).first() + if not user: + raise serializers.ValidationError({"user_id": "No user found with this user_id."}) + + if UserInternGuildLink.objects.filter(user=user).exists(): + raise serializers.ValidationError({"user": "This user is already onboarded as an intern."}) + + attrs['user'] = user + + return attrs + + def _get_role_obj(self, role_title): + """Fetch a Role by title, raising a clean error if it's missing from the DB.""" + role = Role.objects.filter(title=role_title).first() + if not role: + raise serializers.ValidationError( + {"role": f"Role '{role_title}' does not exist in the system."} + ) + return role + + def _assign_role(self, user, role_title, actor_user_id): + """Additively assign the given intern-type role. + + A user can hold both 'Intern' and 'Intern Lead' simultaneously. + Assigning 'Intern Lead' also ensures 'Intern' is present. + Assigning 'Intern' alone does NOT strip 'Intern Lead' if already held. + """ + roles_to_assign = [role_title] + # Intern Lead implies Intern — keep both + if role_title == RoleType.INTERN_LEAD.value: + roles_to_assign.append(RoleType.INTERN.value) + + for title in roles_to_assign: + role = self._get_role_obj(title) + UserRoleLink.objects.get_or_create( + user=user, + role=role, + defaults={'verified': True, 'is_active': True, 'created_by_id': actor_user_id} + ) + + def create(self, validated_data): + actor_user_id = self.context.get('user_id') + role_title = validated_data.pop('role', RoleType.INTERN.value) + + validated_data['created_by_id'] = actor_user_id + validated_data['updated_by_id'] = actor_user_id + validated_data.setdefault('status', InternGuildStatus.ACTIVE.value) + + with transaction.atomic(): + guild_link = super().create(validated_data) + self._assign_role(guild_link.user, role_title, actor_user_id) + return guild_link + + def update(self, instance, validated_data): + actor_user_id = self.context.get('user_id') + role_title = validated_data.pop('role', None) + + validated_data['updated_by_id'] = actor_user_id + + old_status = instance.status + new_status = validated_data.get('status', old_status) + + # Clear previous_status when coming back from ON_LEAVE + if old_status == InternGuildStatus.ON_LEAVE.value and new_status != InternGuildStatus.ON_LEAVE.value: + validated_data['previous_status'] = None + + with transaction.atomic(): + guild_link = super().update(instance, validated_data) + + if new_status == InternGuildStatus.INACTIVE.value: + # Revoke all intern roles on deactivation + UserRoleLink.objects.filter( + user=guild_link.user, + role__title__in=[RoleType.INTERN.value, RoleType.INTERN_LEAD.value] + ).delete() + elif role_title: + # Admin explicitly changing the role + self._assign_role(guild_link.user, role_title, actor_user_id) + + return guild_link diff --git a/api/dashboard/manage_interns/tasks/serializers.py b/api/dashboard/manage_interns/tasks/serializers.py new file mode 100644 index 000000000..2b4f7e00e --- /dev/null +++ b/api/dashboard/manage_interns/tasks/serializers.py @@ -0,0 +1,150 @@ +from rest_framework import serializers +from django.utils.timezone import now +from db.intern import InternTask, UserInternGuildLink +from db.user import User +from utils.types import InternGuildStatus + +COMPLEXITY_WEIGHT_MAP = {'LOW': 1, 'MEDIUM': 2, 'HIGH': 3, 'CRITICAL': 5} + +class UserPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): + def to_internal_value(self, data): + # Try to retrieve user by muid first, then fallback to uuid (pk) + user = User.objects.filter(muid=data).first() + if user: + return user + try: + return super().to_internal_value(data) + except serializers.ValidationError: + raise serializers.ValidationError( + f"User with muid or user_id '{data}' does not exist." + ) + +class ManageInternTaskSerializer(serializers.ModelSerializer): + guild = serializers.CharField(source='team', write_only=True, required=False) + assigned_to = UserPrimaryKeyRelatedField(queryset=User.objects.all()) + iso_week = serializers.IntegerField(required=False) + is_verified = serializers.BooleanField(required=False) + complexity = serializers.ChoiceField( + choices=['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'], + default='LOW' + ) + complexity_score = serializers.SerializerMethodField() + assigned_to_name = serializers.SerializerMethodField() + assigned_to_muid = serializers.SerializerMethodField() + + class Meta: + model = InternTask + fields = [ + 'id', 'title', 'description', 'category', 'complexity', + 'complexity_score', 'assigned_to', 'assigned_to_name', 'assigned_to_muid', + 'status', 'remark', 'team', 'guild', 'deadline', 'iso_week', 'output_link', + 'karma_awarded', 'is_verified', 'verified_by_id', 'created_at' + ] + read_only_fields = ['created_at', 'verified_by_id'] + + def get_complexity_score(self, obj): + return COMPLEXITY_WEIGHT_MAP.get(obj.complexity, 1) + + def get_assigned_to_name(self, obj): + return obj.assigned_to.full_name if obj.assigned_to else None + + def get_assigned_to_muid(self, obj): + return obj.assigned_to.muid if obj.assigned_to else None + + def validate(self, attrs): + # Verify the assigned user is an active intern + assigned_to = attrs.get('assigned_to') + if assigned_to: + guild_link = UserInternGuildLink.objects.filter(user_id=assigned_to.id).first() + if not guild_link or guild_link.status == InternGuildStatus.INACTIVE.value: + raise serializers.ValidationError( + {"assigned_to": f"User '{assigned_to.muid}' is not an active intern."} + ) + + # Prevent creating tasks with a past deadline (only on create, not update) + if not self.instance and 'deadline' in attrs: + if attrs['deadline'] < now().date(): + raise serializers.ValidationError( + {"deadline": "Task deadline cannot be in the past."} + ) + + # Auto-calculate iso_week from deadline if not provided + if 'deadline' in attrs and 'iso_week' not in attrs: + attrs['iso_week'] = attrs['deadline'].isocalendar()[1] + + # Block direct verification via PATCH — must use the /verify/ endpoint + if attrs.get('is_verified') is True: + raise serializers.ValidationError( + "Task verification must be done via the dedicated verification endpoint." + ) + + return attrs + + def create(self, validated_data): + user_id = self.context.get('user_id') + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + + # In case iso_week wasn't passed and wasn't generated by deadline + if 'iso_week' not in validated_data and 'deadline' in validated_data: + validated_data['iso_week'] = validated_data['deadline'].isocalendar()[1] + + return super().create(validated_data) + + def update(self, instance, validated_data): + from django.db import transaction + from django.db.models import F + from django.utils.timezone import now + from db.task import KarmaActivityLog, TaskList, Wallet + from utils.types import InternHashtag + + user_id = self.context.get('user_id') + validated_data['updated_by_id'] = user_id + + # Determine if we should revert verification: + # Either admin explicitly sets is_verified=False, or changes the task status + new_is_verified = validated_data.get('is_verified') + new_status = validated_data.get('status') + + should_revert = ( + instance.is_verified and ( + new_is_verified is False or + (new_status is not None and new_status != instance.status) + ) + ) + + if should_revert: + with transaction.atomic(): + if instance.karma_awarded > 0: + # Deduct karma from the intern's wallet + Wallet.objects.filter(user_id=instance.assigned_to_id).update( + karma=F('karma') - instance.karma_awarded + ) + + # Delete the corresponding KarmaActivityLog entry + task_list = TaskList.objects.filter( + hashtag=InternHashtag.TASK_VERIFIED_HASHTAG.value + ).first() + if task_list: + # Primary lookup: by intern task ID stored in task_message_id + karma_log = KarmaActivityLog.objects.filter( + user_id=instance.assigned_to_id, + task=task_list, + task_message_id=instance.id + ).first() + # Fallback for older entries without task_message_id + if not karma_log: + karma_log = KarmaActivityLog.objects.filter( + user_id=instance.assigned_to_id, + task=task_list, + karma=instance.karma_awarded + ).order_by('-created_at').first() + if karma_log: + karma_log.delete() + + # Reset verification fields + validated_data['is_verified'] = False + validated_data['verified_by_id'] = None + validated_data['karma_awarded'] = 0 + + return super().update(instance, validated_data) diff --git a/api/dashboard/manage_interns/tasks/tasks_views.py b/api/dashboard/manage_interns/tasks/tasks_views.py new file mode 100644 index 000000000..5fb2b3c73 --- /dev/null +++ b/api/dashboard/manage_interns/tasks/tasks_views.py @@ -0,0 +1,232 @@ +import json +import uuid +from rest_framework.views import APIView +from django.utils.timezone import now +from django.db import transaction +from django.db.models import F +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType, InternHashtag +from utils.utils import CommonUtils +from db.intern import InternTask +from db.task import KarmaActivityLog, TaskList, Wallet +from .serializers import ManageInternTaskSerializer + +class ManageInternTaskAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve intern task(s) or task detail.", + responses={200: ManageInternTaskSerializer(many=True)}, + ) + def get(self, request, task_id=None): + if task_id: + task = InternTask.objects.filter(id=task_id).first() + if not task: + return CustomResponse(general_message="Task not found.").get_failure_response() + serializer = ManageInternTaskSerializer(task) + return CustomResponse(response=serializer.data).get_success_response() + + tasks = InternTask.objects.select_related('assigned_to').all().order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + tasks, request, + ['title', 'status', 'category', 'assigned_to__full_name'], + {'created_at': 'created_at', 'status': 'status'} + ) + + serializer = ManageInternTaskSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Create an intern task.", + responses={200: OpenApiResponse(description="Task created successfully.")}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + serializer = ManageInternTaskSerializer(data=request.data, context={'user_id': user_id}) + + if serializer.is_valid(): + serializer.save() + return CustomResponse(general_message="Task created successfully.").get_success_response() + + return CustomResponse(response=serializer.errors).get_failure_response() + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Update an intern task (partial update).", + responses={200: OpenApiResponse(description="Task updated successfully.")}, + ) + def patch(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + + task = InternTask.objects.filter(id=task_id).first() + if not task: + return CustomResponse(general_message="Task not found.").get_failure_response() + + request_data = request.data + + old_data = { + "title": task.title, + "description": task.description, + "category": task.category, + "complexity": task.complexity, + "assigned_to_id": task.assigned_to_id, + "status": task.status + } + + serializer = ManageInternTaskSerializer(task, data=request_data, partial=True, context={'user_id': user_id}) + + if serializer.is_valid(): + serializer.save() + + from db.mentor import SystemActionLog + new_data = {k: v for k, v in request_data.items() if k in old_data} + + if new_data: + SystemActionLog.objects.create( + action_type=SystemActionLog.ActionType.INTERN_TASK_UPDATE.value, + actor_user_id=user_id, + subject_user_id=task.assigned_to_id, + entity_name='intern_task', + entity_id=task.id, + old_data=old_data, + new_data=new_data + ) + + return CustomResponse(general_message="Task updated successfully.").get_success_response() + + return CustomResponse(response=serializer.errors).get_failure_response() + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Delete an intern task.", + responses={200: OpenApiResponse(description="Task deleted successfully.")}, + ) + def delete(self, request, task_id): + task = InternTask.objects.filter(id=task_id).first() + if not task: + return CustomResponse(general_message="Task not found.").get_failure_response() + + if task.is_verified: + return CustomResponse(general_message="Cannot delete a verified task. Revoke verification first.").get_failure_response() + + task.delete() + return CustomResponse(general_message="Task deleted successfully.").get_success_response() + +class ManageInternTasksByInternAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Retrieve all tasks assigned to a specific intern identified by their muid.", + responses={200: ManageInternTaskSerializer(many=True)}, + ) + def get(self, request, muid): + from db.user import User + user = User.objects.filter(muid=muid).first() + if not user: + return CustomResponse(general_message=f"User with muid '{muid}' not found.").get_failure_response() + + tasks = InternTask.objects.select_related('assigned_to').filter(assigned_to_id=user.id).order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + tasks, request, + ['title', 'status', 'category'], + {'created_at': 'created_at', 'status': 'status', 'deadline': 'deadline'} + ) + + serializer = ManageInternTaskSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination") + } + ).get_success_response() + +class ManageInternTaskVerifyAPI(APIView): + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value, RoleType.INTERN.value, RoleType.INTERN_LEAD.value]) + @extend_schema( + tags=['Dashboard - Intern'], + description="Verify an intern task and award karma.", + responses={200: OpenApiResponse(description="Task verified successfully.")}, + ) + def post(self, request, task_id): + admin_id = JWTUtils.fetch_user_id(request) + + task = InternTask.objects.filter(id=task_id).first() + if not task: + return CustomResponse(general_message="Task not found.").get_failure_response() + + if task.is_verified: + return CustomResponse(general_message="Task is already verified.").get_failure_response() + + karma_awarded = request.data.get('karma_awarded', 0) + try: + karma_awarded = int(karma_awarded) + except (ValueError, TypeError): + return CustomResponse(general_message="karma_awarded must be an integer.").get_failure_response() + + if karma_awarded < 0: + return CustomResponse(general_message="karma_awarded cannot be negative.").get_failure_response() + + intern_user_id = task.assigned_to_id + + with transaction.atomic(): + task.is_verified = True + task.verified_by_id = admin_id + task.karma_awarded = karma_awarded + task.save() + + # Award karma to intern's wallet and log it. + if karma_awarded > 0: + task_list = TaskList.objects.filter( + hashtag=InternHashtag.TASK_VERIFIED_HASHTAG.value + ).first() + if task_list: + KarmaActivityLog.objects.create( + id=str(uuid.uuid4()), + user_id=intern_user_id, + task=task_list, + karma=karma_awarded, + appraiser_approved=True, + task_message_id=task.id, # Store intern task ID for karma revert tracking + updated_by_id=admin_id, + updated_at=now(), + created_by_id=admin_id, + created_at=now() + ) + wallet, _ = Wallet.objects.get_or_create( + user_id=intern_user_id, + defaults={'created_by_id': admin_id, 'updated_by_id': admin_id} + ) + Wallet.objects.filter(id=wallet.id).update(karma=F('karma') + karma_awarded) + + from db.mentor import SystemActionLog + SystemActionLog.objects.create( + action_type=SystemActionLog.ActionType.INTERN_TASK_UPDATE.value, + actor_user_id=admin_id, + subject_user_id=intern_user_id, + entity_name='intern_task', + entity_id=task.id, + old_data={"is_verified": False, "karma_awarded": 0}, + new_data={"is_verified": True, "karma_awarded": karma_awarded, "verified_by": admin_id} + ) + + return CustomResponse(general_message="Task verified successfully.").get_success_response() diff --git a/api/dashboard/manage_interns/tasks/urls.py b/api/dashboard/manage_interns/tasks/urls.py new file mode 100644 index 000000000..cb9e5fbc2 --- /dev/null +++ b/api/dashboard/manage_interns/tasks/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import tasks_views + +urlpatterns = [ + path("", tasks_views.ManageInternTaskAPI.as_view(), name="manage-intern-task-list-create"), + path("by-intern//", tasks_views.ManageInternTasksByInternAPI.as_view(), name="manage-intern-tasks-by-intern"), + path("/verify/", tasks_views.ManageInternTaskVerifyAPI.as_view(), name="manage-intern-task-verify"), + path("/", tasks_views.ManageInternTaskAPI.as_view(), name="manage-intern-task-update-delete"), +] diff --git a/api/dashboard/manage_interns/urls.py b/api/dashboard/manage_interns/urls.py new file mode 100644 index 000000000..a9ea0cadf --- /dev/null +++ b/api/dashboard/manage_interns/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, include +from . import interns_views + +urlpatterns = [ + path("reviews/", include("api.dashboard.manage_interns.reviews.urls")), + path("tasks/", include("api.dashboard.manage_interns.tasks.urls")), + path("leave/", include("api.dashboard.manage_interns.leave.urls")), + + path("status/", interns_views.ManageInternStatusAPI.as_view(), name="manage-intern-status"), + path("interns/export/", interns_views.ManageInternExportAPI.as_view(), name="manage-intern-export"), + path("interns/import/template/", interns_views.ManageInternBulkImportTemplateAPIView.as_view(), name="manage-intern-bulk-import-template"), + path("interns/import/", interns_views.ManageInternBulkImportAPI.as_view(), name="manage-intern-bulk-import"), + path("interns/", interns_views.ManageInternAPI.as_view(), name="manage-intern-list-create"), + path("interns//", interns_views.ManageInternAPI.as_view(), name="manage-intern-update-delete"), +] diff --git a/api/dashboard/media_content/__init__.py b/api/dashboard/media_content/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/dashboard/media_content/image_utils.py b/api/dashboard/media_content/image_utils.py new file mode 100644 index 000000000..b5b2ced1b --- /dev/null +++ b/api/dashboard/media_content/image_utils.py @@ -0,0 +1,507 @@ +""" +Media content images: validate uploads, fetch remote URLs safely, resolve public URLs. + +Ported from api/dashboard/events/event_image_utils.py so that the media_content +module is fully self-contained and does not depend on the events app. +""" +from __future__ import annotations + +import ipaddress +import os +import socket +import threading +import uuid +from io import BytesIO +from urllib.parse import urljoin, urlparse + +import requests +import requests.adapters +import urllib3 +import urllib3.util.connection +from decouple import config +from django.conf import settings +from PIL import Image + +MAX_BYTES = 5 * 1024 * 1024 +ALLOWED_EXT = frozenset({'png', 'jpg', 'jpeg', 'gif', 'webp'}) +FETCH_TIMEOUT = 15 +MAX_REDIRECTS = 5 + +# PIL format -> file extension (stored in DB path) +_PIL_FORMAT_EXT = { + 'PNG': 'png', + 'JPEG': 'jpg', + 'GIF': 'gif', + 'WEBP': 'webp', +} + + +def _extension_from_filename(name: str) -> str: + if not name or '.' not in name: + return '' + return name.rsplit('.', 1)[-1].lower() + + +def _validate_extension(ext: str) -> bool: + return ext in ALLOWED_EXT + + +def _ensure_dir(subdir: str) -> str: + upload_dir = os.path.join(settings.MEDIA_ROOT, 'media_content', subdir) + os.makedirs(upload_dir, exist_ok=True) + return upload_dir + + +def save_uploaded_image(upload, subdir: str) -> tuple[str | None, str | None]: + """ + Save an uploaded file under MEDIA_ROOT/media_content//. + Returns (relative_path, error_message). + """ + ext = _extension_from_filename(getattr(upload, 'name', '') or '') + if not _validate_extension(ext): + return None, ( + f'Invalid image type. Allowed: {", ".join(sorted(ALLOWED_EXT))}' + ) + if upload.size > MAX_BYTES: + return None, 'File size exceeds 5MB limit' + + upload.seek(0) + raw = upload.read(MAX_BYTES + 1) + if len(raw) > MAX_BYTES: + return None, 'File size exceeds 5MB limit' + + ext2, err = _validate_image_bytes(raw, ext) + if err: + return None, err + final_ext = ext2 or ext + + unique = f'{uuid.uuid4()}.{final_ext}' + upload_dir = _ensure_dir(subdir) + rel = f'media_content/{subdir}/{unique}' + abs_path = os.path.join(upload_dir, unique) + with open(abs_path, 'wb+') as dest: + dest.write(raw) + return rel, None + + +def _validate_image_bytes(data: bytes, filename_ext: str) -> tuple[str | None, str | None]: + """Returns (canonical_ext or None, error).""" + try: + img = Image.open(BytesIO(data)) + img.verify() + except Exception: + return None, 'Invalid or corrupted image file' + + try: + img = Image.open(BytesIO(data)) + fmt = (img.format or '').upper() + pil_ext = _PIL_FORMAT_EXT.get(fmt) + if not pil_ext: + return None, 'Unsupported image format' + if not _validate_extension(pil_ext): + return None, 'Unsupported image format' + if filename_ext and filename_ext != pil_ext and not ( + filename_ext == 'jpeg' and pil_ext == 'jpg' + ): + return pil_ext, None + return pil_ext, None + except Exception: + return None, 'Invalid or corrupted image file' + + +def _hostname_is_blocked(hostname: str) -> bool: + """Return True if *all* resolved IPs for hostname are non-routable/private. + + This is intentionally strict: if *any* resolved address is in a + disallowed range we block the whole hostname. + """ + if not hostname: + return True + try: + infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except OSError: + return True + for info in infos: + ip_str = info[4][0] + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + continue + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + ): + return True + if ip.version == 6 and ip in ipaddress.ip_network('fc00::/7'): + return True + return False + + +def _url_is_safe_for_fetch(url: str) -> bool: + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https'): + return False + if not parsed.hostname: + return False + if '@' in parsed.netloc: + return False + return not _hostname_is_blocked(parsed.hostname) + + +# Thread-local storage used by _SSRFBlockingAdapter to pass the pre-validated +# IP to our patched create_connection without touching global state. +_ssrf_local = threading.local() + +# Keep a reference to the real urllib3 create_connection so we can call it +# after substituting the resolved (and validated) IP address. +_real_create_connection = urllib3.util.connection.create_connection + + +def _pinned_create_connection(address, *args, **kwargs): + """ + Drop-in replacement for ``urllib3.util.connection.create_connection``. + + When ``_ssrf_local.pinned_ip`` is set by ``_SSRFBlockingAdapter.send`` + for the current thread, the hostname in *address* is silently replaced + with the pre-validated IP so urllib3 connects to that address directly + instead of re-resolving DNS. The original hostname is preserved in the + URL, so urllib3 still uses it for TLS SNI and certificate verification. + + Outside of an ``_SSRFBlockingAdapter`` call the function is transparent. + """ + pinned_ip = getattr(_ssrf_local, 'pinned_ip', None) + if pinned_ip is not None: + host, port = address + address = (pinned_ip, port) + return _real_create_connection(address, *args, **kwargs) + + +# Patch once at import time. This is the same technique used by the +# ``responses`` test library and various urllib3 extension packages. +urllib3.util.connection.create_connection = _pinned_create_connection +# urllib3 imports create_connection into the connection module's namespace too. +urllib3.connection.HTTPConnection.is_verified # ensure module is imported +import urllib3.connection as _urllib3_connection # noqa: E402 +_urllib3_connection.create_connection = _pinned_create_connection + + +class _SSRFBlockingAdapter(requests.adapters.HTTPAdapter): + """ + A requests transport adapter that closes the DNS-rebinding window. + + Standard SSRF guards resolve the hostname once for the IP check and + then let the OS resolver re-resolve it when the socket is opened — + leaving a TOCTOU window. This adapter eliminates that gap by: + + 1. Resolving the hostname to an IP via ``socket.getaddrinfo``. + 2. Validating the resolved IP with ``_hostname_is_blocked``. + 3. Pinning the TCP connection to that IP at the socket layer so + urllib3 never performs a second DNS lookup. + + Crucially, the request URL is **not** rewritten. urllib3 therefore + derives ``server_hostname`` from the original hostname, which means + TLS SNI is sent correctly and the server certificate is verified + against the hostname — not against a raw IP literal that almost no + CDN certificate covers. + """ + + def send(self, request, **kwargs): + parsed = urlparse(request.url) + hostname = parsed.hostname + port = parsed.port + + if not hostname: + raise requests.exceptions.InvalidURL('Missing hostname in URL') + + # Resolve once, validate once — the actual connection uses this IP. + try: + infos = socket.getaddrinfo( + hostname, + port, + type=socket.SOCK_STREAM, + ) + except OSError as exc: + raise requests.exceptions.ConnectionError( + f'DNS resolution failed for {hostname!r}: {exc}' + ) from exc + + if not infos: + raise requests.exceptions.ConnectionError( + f'DNS resolution returned no results for {hostname!r}' + ) + + ip_str = infos[0][4][0] + try: + ip_obj = ipaddress.ip_address(ip_str) + except ValueError as exc: + raise requests.exceptions.ConnectionError( + f'Unparseable IP address {ip_str!r} for {hostname!r}' + ) from exc + + if ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_reserved + or ip_obj.is_multicast + or (ip_obj.version == 6 and ip_obj in ipaddress.ip_network('fc00::/7')) + ): + raise requests.exceptions.ConnectionError( + f'URL resolves to a blocked address ({ip_str})' + ) + + # Inject the pre-validated IP into the thread-local so that + # _pinned_create_connection uses it instead of re-resolving DNS. + # The URL itself is left unchanged so that urllib3's TLS layer + # sends the correct SNI extension and verifies the certificate + # against the original hostname. + _ssrf_local.pinned_ip = ip_str + try: + return super().send(request, **kwargs) + finally: + # Always clear the pin, even on exception, to avoid leaking + # state into unrelated requests on the same thread. + _ssrf_local.pinned_ip = None + + +def try_normalize_media_url_to_relative(url: str) -> str | None: + """If url points at this deployment's MEDIA_URL, return stored relative path.""" + u = (url or '').strip() + if not u.startswith(('http://', 'https://')): + return None + base = config('BE_DOMAIN_NAME', default='').rstrip('/') + if not base: + return None + prefix = f'{base}{settings.MEDIA_URL}'.rstrip('/') + '/' + if u.startswith(prefix): + return u[len(prefix):].lstrip('/') + return None + + +def fetch_image_from_url(url: str, subdir: str) -> tuple[str | None, str | None]: + """ + Download an image from a remote URL with SSRF checks and size limits. + Returns (relative_path, error_message). + """ + u = (url or '').strip() + if not u: + return None, 'Empty image URL' + + normalized = try_normalize_media_url_to_relative(u) + if normalized: + return normalized, None + + if not u.startswith(('http://', 'https://')): + # Relative path or opaque string — store as-is (legacy) + return u, None + + current = u + session = requests.Session() + # Mount the SSRF-blocking adapter for both schemes. It resolves DNS + # exactly once per redirect hop, validates the IP, and pins the + # socket to that address — closing the DNS-rebinding TOCTOU window. + _adapter = _SSRFBlockingAdapter() + session.mount('http://', _adapter) + session.mount('https://', _adapter) + + for _ in range(MAX_REDIRECTS + 1): + # Fast-path structural check (scheme, credentials in netloc). + # The adapter enforces the IP-level block at connect time. + if not _url_is_safe_for_fetch(current): + return None, 'URL is not allowed' + try: + resp = session.get( + current, + timeout=FETCH_TIMEOUT, + stream=True, + allow_redirects=False, + headers={'User-Agent': 'mulearnbackend-media-image/1.0'}, + ) + except requests.ConnectionError: + return None, 'URL is not allowed' + except requests.RequestException: + return None, 'Failed to download image: connection error' + + if resp.status_code in (301, 302, 303, 307, 308): + loc = resp.headers.get('Location') + if not loc: + return None, 'Redirect without Location header' + current = urljoin(current, loc) + continue + + if resp.status_code != 200: + return None, f'Failed to download image (HTTP {resp.status_code})' + + chunks: list[bytes] = [] + total = 0 + for chunk in resp.iter_content(chunk_size=65536): + if not chunk: + continue + total += len(chunk) + if total > MAX_BYTES: + return None, 'Downloaded file exceeds 5MB limit' + chunks.append(chunk) + data = b''.join(chunks) + break + else: + return None, 'Too many redirects' + + ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip().lower() + if ctype and not ctype.startswith('image/'): + return None, 'URL did not return an image' + + ext_guess = 'jpg' + if 'png' in ctype: + ext_guess = 'png' + elif 'jpeg' in ctype or 'jpg' in ctype: + ext_guess = 'jpg' + elif 'gif' in ctype: + ext_guess = 'gif' + elif 'webp' in ctype: + ext_guess = 'webp' + + ext2, err = _validate_image_bytes(data, ext_guess) + if err: + return None, err + final_ext = ext2 or ext_guess + + unique = f'{uuid.uuid4()}.{final_ext}' + upload_dir = _ensure_dir(subdir) + rel = f'media_content/{subdir}/{unique}' + abs_path = os.path.join(upload_dir, unique) + with open(abs_path, 'wb+') as dest: + dest.write(data) + return rel, None + + +def delete_stale_media(old_path: str | None, new_path: str | None) -> None: + """Remove a previously stored file under media_content/ when replaced.""" + if not old_path or old_path == new_path: + return + if not old_path.startswith('media_content/'): + return + full = os.path.join(settings.MEDIA_ROOT, old_path.replace('/', os.sep)) + if os.path.isfile(full): + try: + os.remove(full) + except OSError: + pass + + +def resolve_image_url(value: str | None, request=None) -> str | None: + """ + Build an absolute URL for API responses. + Relative paths under media get BE_DOMAIN_NAME + MEDIA_URL; existing http(s) left as-is. + """ + if not value: + return None + v = value.strip() + if not v: + return None + if v.startswith('http://') or v.startswith('https://'): + return v + base = config('BE_DOMAIN_NAME', default='').rstrip('/') + path = v.lstrip('/') + rel_url = f'{settings.MEDIA_URL.rstrip("/")}/{path}' + if base: + return f'{base}{rel_url}' + if request: + return request.build_absolute_uri(rel_url) + return rel_url + + +def _querydict_to_plain_dict(data) -> dict: + """Single-value snapshot of QueryDict / dict for serializer input.""" + out = {} + try: + keys = getattr(data, 'keys', lambda: [])() + for k in keys: + out[k] = data.get(k) + except Exception: + out = dict(data) if hasattr(data, 'items') else {} + return out + + +def merge_media_write_payload( + request, + *, + partial: bool, + image_fields: tuple[tuple[str, str], ...] = (('poster_thumbnail', 'posters'),), + skip_remote_fetch: bool = False, +) -> tuple[dict | None, str | None, dict[str, tuple[str, str]]]: + """ + Merge multipart/JSON body with resolved image paths. + + For each (field, subdir) pair in image_fields: + - If the field is present in request.FILES, validate and save the upload. + - If the field is present in request.data as a URL: + * When skip_remote_fetch=False (default): fetch the remote image + synchronously and store the resulting relative path. + * When skip_remote_fetch=True: leave the field as None in the + payload and report the pending URL in pending_url_fields so the + caller can dispatch a Celery task instead of blocking the worker. + - If partial=True and the field is absent, leave it untouched. + + Returns (payload_dict, error_message, pending_url_fields). + pending_url_fields maps field -> (raw_url, subdir) for every remote URL + that was deferred when skip_remote_fetch=True. + """ + payload = _querydict_to_plain_dict(request.data) + pending_url_fields: dict[str, tuple[str, str]] = {} + + for field, subdir in image_fields: + upload = request.FILES.get(field) + if upload: + rel, err = save_uploaded_image(upload, subdir) + if err: + return None, err, {} + payload[field] = rel + continue + + if partial: + has_key = field in request.data + if not has_key: + payload.pop(field, None) + continue + else: + has_key = field in request.data + if not has_key: + continue + + raw = request.data.get(field) + if raw is None: + payload[field] = None + continue + if isinstance(raw, str): + raw = raw.strip() + if raw == '' or raw is None: + payload[field] = None + continue + + if not isinstance(raw, str): + payload[field] = raw + continue + + normalized = try_normalize_media_url_to_relative(raw) + if normalized: + payload[field] = normalized + continue + + if raw.lower().startswith(('http://', 'https://')): + if skip_remote_fetch: + # Defer the download to a Celery task; the field is stored + # as None until the task completes. + payload[field] = None + pending_url_fields[field] = (raw, subdir) + else: + rel, err = fetch_image_from_url(raw, subdir) + if err: + return None, err, {} + payload[field] = rel + else: + payload[field] = raw + + return payload, None, pending_url_fields diff --git a/api/dashboard/media_content/serializers.py b/api/dashboard/media_content/serializers.py new file mode 100644 index 000000000..a5db0fd67 --- /dev/null +++ b/api/dashboard/media_content/serializers.py @@ -0,0 +1,198 @@ +""" +Serializers for the MediaContent module. + +Read serializers — returned in GET responses; include computed `is_upcoming`. +Write serializers — used for POST (create) and PATCH (partial update); validate + type-specific required fields, date formats, and enum values. +""" +import uuid +from datetime import date + +from rest_framework import serializers + +from api.dashboard.media_content.image_utils import resolve_image_url +from db.events import MediaContent + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _compute_status(record_date: date) -> str: + """Compute status based on date relative to today.""" + today = date.today() + if record_date > today: + return 'upcoming' + elif record_date == today: + return 'ongoing' + else: + return 'completed' + + +# ───────────────────────────────────────────────────────────────────────────── +# READ serializers +# ───────────────────────────────────────────────────────────────────────────── + +class OfficeHoursReadSerializer(serializers.ModelSerializer): + """ + Read-only serializer for Office Hours sessions. + Exposes all Office Hours fields plus a computed ``status`` flag. + """ + status = serializers.SerializerMethodField() + poster_thumbnail = serializers.SerializerMethodField() + + class Meta: + model = MediaContent + fields = [ + 'id', + 'title', + 'performer', + 'designation', + 'description', + 'date', + 'link', + 'interest_groups', + 'poster_thumbnail', + 'status', + 'created_at', + 'updated_at', + ] + + def get_status(self, obj): + return _compute_status(obj.date) + + def get_poster_thumbnail(self, obj): + return resolve_image_url(obj.poster_thumbnail, self.context.get('request')) + + +class SaltMangoTreeReadSerializer(serializers.ModelSerializer): + """ + Read-only serializer for Salt Mango Tree episodes. + Exposes topic (stored as ``title``), campus, zone, and ``status``. + """ + # CMS used "topic"; we alias `title` → `topic` in the response. + topic = serializers.CharField(source='title') + status = serializers.SerializerMethodField() + + class Meta: + model = MediaContent + fields = [ + 'id', + 'topic', + 'campus', + 'zone', + 'date', + 'description', + 'link', + 'status', + 'created_at', + 'updated_at', + ] + + def get_status(self, obj): + return _compute_status(obj.date) + + +class InspirationStationReadSerializer(serializers.ModelSerializer): + """ + Read-only serializer for Inspiration Station Radio episodes. + Same structure as SMT — topic (``title``), campus, zone, ``status``. + """ + topic = serializers.CharField(source='title') + status = serializers.SerializerMethodField() + + class Meta: + model = MediaContent + fields = [ + 'id', + 'topic', + 'campus', + 'zone', + 'date', + 'description', + 'link', + 'status', + 'created_at', + 'updated_at', + ] + + def get_status(self, obj): + return _compute_status(obj.date) + + +# ───────────────────────────────────────────────────────────────────────────── +# WRITE serializers +# ───────────────────────────────────────────────────────────────────────────── + +class OfficeHoursWriteSerializer(serializers.Serializer): + """ + Write serializer for Office Hours sessions (POST / PATCH). + + Date format accepted: DD/MM/YYYY (matches the CMS schema). + It is converted to a Python ``date`` object for DB storage. + """ + title = serializers.CharField(max_length=300) + date = serializers.CharField( + help_text='Format: DD/MM/YYYY' + ) + performer = serializers.CharField(max_length=200, required=False, allow_blank=True, allow_null=True) + designation = serializers.CharField(max_length=200, required=False, allow_blank=True, allow_null=True) + description = serializers.CharField(required=False, allow_blank=True, allow_null=True) + link = serializers.URLField(max_length=500, required=False, allow_blank=True, allow_null=True) + interest_groups = serializers.ListField( + child=serializers.CharField(), required=False, allow_null=True + ) + poster_thumbnail = serializers.CharField(max_length=512, required=False, allow_blank=True, allow_null=True) + + def validate_date(self, value): + from datetime import datetime + try: + return datetime.strptime(value, '%d/%m/%Y').date() + except ValueError: + raise serializers.ValidationError( + "Invalid date format. Expected DD/MM/YYYY (e.g. 27/06/2025)." + ) + + def to_internal_value(self, data): + value = super().to_internal_value(data) + value['content_type'] = MediaContent.ContentType.OFFICE_HOURS + return value + + +class _EpisodeWriteSerializer(serializers.Serializer): + """ + Shared base for SMT and Inspiration Station write serializers. + Date format accepted: YYYY-MM-DD (matches the CMS schema). + """ + topic = serializers.CharField(max_length=300) # maps → title + campus = serializers.CharField(max_length=200) + zone = serializers.ChoiceField( + choices=MediaContent.Zone.choices, required=False, allow_null=True + ) + date = serializers.DateField( + input_formats=['%Y-%m-%d'], + help_text='Format: YYYY-MM-DD' + ) + description = serializers.CharField(required=False, allow_blank=True, allow_null=True) + link = serializers.URLField(max_length=500, required=False, allow_blank=True, allow_null=True) + + def to_internal_value(self, data): + value = super().to_internal_value(data) + # Remap "topic" → "title" so it aligns with the DB column name + if 'topic' in value: + value['title'] = value.pop('topic') + return value + + +class SaltMangoTreeWriteSerializer(_EpisodeWriteSerializer): + def to_internal_value(self, data): + value = super().to_internal_value(data) + value['content_type'] = MediaContent.ContentType.SALT_MANGO_TREE + return value + + +class InspirationStationWriteSerializer(_EpisodeWriteSerializer): + def to_internal_value(self, data): + value = super().to_internal_value(data) + value['content_type'] = MediaContent.ContentType.INSPIRATION_STATION + return value diff --git a/api/dashboard/media_content/urls.py b/api/dashboard/media_content/urls.py new file mode 100644 index 000000000..1d8ac343f --- /dev/null +++ b/api/dashboard/media_content/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + # ── Office Hours ───────────────────────────────────────────────────────── + path('office-hours/', views.OfficeHoursListCreateAPI.as_view()), + path('office-hours//', views.OfficeHoursDetailAPI.as_view()), + + # ── Salt Mango Tree ────────────────────────────────────────────────────── + path('salt-mango-tree/', views.SaltMangoTreeListCreateAPI.as_view()), + path('salt-mango-tree//', views.SaltMangoTreeDetailAPI.as_view()), + + # ── Inspiration Station Radio ──────────────────────────────────────────── + path('inspiration-station/', views.InspirationStationListCreateAPI.as_view()), + path('inspiration-station//', views.InspirationStationDetailAPI.as_view()), + path('bulk/import/', views.MediaContentBulkImportAPI.as_view()), + path('bulk/export//', views.MediaContentBulkExportAPI.as_view()) +] diff --git a/api/dashboard/media_content/views.py b/api/dashboard/media_content/views.py new file mode 100644 index 000000000..aefd64986 --- /dev/null +++ b/api/dashboard/media_content/views.py @@ -0,0 +1,664 @@ +""" +Media Content API views. + +Exposes CRUD endpoints for three CMS-migrated content types backed by the +single ``MediaContent`` model: + + - Office Hours → /media-content/office-hours/ + - Salt Mango Tree → /media-content/salt-mango-tree/ + - Inspiration Station → /media-content/inspiration-station/ + +Read (GET) endpoints are publicly accessible. +Write (POST / PATCH / DELETE) endpoints require the ADMIN role. +""" +import uuid + +from django.utils import timezone +from rest_framework.views import APIView + +from db.events import MediaContent +from utils.permission import CustomizePermission, JWTUtils, RoleRequired +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +import csv +import codecs + +from .serializers import ( + OfficeHoursReadSerializer, + OfficeHoursWriteSerializer, + SaltMangoTreeReadSerializer, + SaltMangoTreeWriteSerializer, + InspirationStationReadSerializer, + InspirationStationWriteSerializer, +) +from api.dashboard.media_content.image_utils import ( + merge_media_write_payload, + delete_stale_media, +) +from mu_celery.media_content_tasks import fetch_and_attach_poster + +from drf_spectacular.utils import extend_schema + + +# ───────────────────────────────────────────────────────────────────────────── +# Shared helpers +# ───────────────────────────────────────────────────────────────────────────── + +class PublicGetMixin: + """ + Mixin to bypass JWT authentication for GET requests. + """ + def get_authenticators(self): + if getattr(self, 'request', None) and self.request.method == 'GET': + return [] + return super().get_authenticators() + +def _base_qs(content_type: str): + """Live (non-deleted) queryset for a given content type.""" + return MediaContent.objects.filter( + content_type=content_type, + deleted_at__isnull=True, + ).order_by('-date', '-created_at') + + +def _apply_common_filters(qs, request, *, has_zone: bool = False): + """Apply shared query-param filters to a MediaContent queryset.""" + params = request.query_params + from datetime import date + + if status := params.get('status'): + status = status.lower() + if status == 'upcoming': + qs = qs.filter(date__gt=date.today()) + elif status == 'ongoing': + qs = qs.filter(date=date.today()) + elif status == 'completed': + qs = qs.filter(date__lt=date.today()) + + if has_zone: + if zone := params.get('zone'): + qs = qs.filter(zone=zone) + + return qs + + +# ───────────────────────────────────────────────────────────────────────────── +# Office Hours +# ───────────────────────────────────────────────────────────────────────────── + + + + + +class OfficeHoursListCreateAPI(PublicGetMixin, APIView): + """ + GET /media-content/office-hours/ — Public paginated list of sessions. + POST /media-content/office-hours/ — Create a session (Admin only). + """ + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Media Content - Office Hours']) + def get(self, request): + qs = _base_qs(MediaContent.ContentType.OFFICE_HOURS) + qs = _apply_common_filters(qs, request, has_zone=False) + + paginated = CommonUtils.get_paginated_queryset( + qs, request, + search_fields=['title', 'performer', 'description'], + sort_fields={ + 'date': 'date', + 'created_at': 'created_at', + }, + ) + + serializer = OfficeHoursReadSerializer(paginated['queryset'], many=True, context={'request': request}) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + + @extend_schema(tags=['Media Content - Office Hours']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + + payload, merge_error, pending_urls = merge_media_write_payload( + request, partial=False, skip_remote_fetch=True + ) + if merge_error: + return CustomResponse( + general_message=merge_error, + ).get_failure_response() + + serializer = OfficeHoursWriteSerializer(data=payload) + if not serializer.is_valid(): + return CustomResponse( + general_message='Invalid data.', + message=serializer.errors, + ).get_failure_response() + + data = serializer.validated_data + record = MediaContent.objects.create( + id=str(uuid.uuid4()), + created_by_id=user_id, + updated_by_id=user_id, + **data, + ) + + for field, (raw_url, subdir) in pending_urls.items(): + fetch_and_attach_poster.delay(record.id, raw_url, subdir, field) + + return CustomResponse( + general_message='Office Hours session created successfully.', + response=OfficeHoursReadSerializer(record, context={'request': request}).data, + ).get_success_response() + + +class OfficeHoursDetailAPI(PublicGetMixin, APIView): + """ + GET /media-content/office-hours// — Public session detail. + PATCH /media-content/office-hours// — Partial update (Admin only). + DELETE /media-content/office-hours// — Soft-delete (Admin only). + """ + authentication_classes = [CustomizePermission] + + def _get_record(self, record_id): + return MediaContent.objects.filter( + id=record_id, + content_type=MediaContent.ContentType.OFFICE_HOURS, + deleted_at__isnull=True, + ).first() + + @extend_schema(tags=['Media Content - Office Hours']) + def get(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Office Hours session not found.' + ).get_failure_response() + + serializer = OfficeHoursReadSerializer(record, context={'request': request}) + return CustomResponse( + general_message='Office Hours session retrieved.', + response=serializer.data, + ).get_success_response() + + + @extend_schema(tags=['Media Content - Office Hours']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def patch(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Office Hours session not found.' + ).get_failure_response() + + payload, merge_error, pending_urls = merge_media_write_payload( + request, partial=True, skip_remote_fetch=True + ) + if merge_error: + return CustomResponse( + general_message=merge_error, + ).get_failure_response() + + serializer = OfficeHoursWriteSerializer(data=payload, partial=True) + if not serializer.is_valid(): + return CustomResponse( + general_message='Invalid data.', + message=serializer.errors, + ).get_failure_response() + + user_id = JWTUtils.fetch_user_id(request) + data = serializer.validated_data + data.pop('content_type', None) # never allow overriding the discriminator + + old_poster = record.poster_thumbnail + for attr, value in data.items(): + setattr(record, attr, value) + record.updated_by_id = user_id + record.save() + + # Only delete the old file when its replacement is already in hand + # (i.e. the field was resolved synchronously via a direct upload or + # an already-relative path). For deferred URL fields the old path is + # forwarded to the Celery task, which removes it *after* the new file + # is confirmed written — preventing unrecoverable data loss if the + # task exhausts its retries. + poster_field_deferred = 'poster_thumbnail' in pending_urls + if not poster_field_deferred: + delete_stale_media(old_poster, record.poster_thumbnail) + + for field, (raw_url, subdir) in pending_urls.items(): + field_old_path = old_poster if field == 'poster_thumbnail' else None + fetch_and_attach_poster.delay(record.id, raw_url, subdir, field, field_old_path) + + return CustomResponse( + general_message='Office Hours session updated.', + response=OfficeHoursReadSerializer(record, context={'request': request}).data, + ).get_success_response() + + + + @extend_schema(tags=['Media Content - Office Hours']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def delete(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Office Hours session not found.' + ).get_failure_response() + + record.deleted_at = timezone.now() + record.updated_by_id = JWTUtils.fetch_user_id(request) + record.save(update_fields=['deleted_at', 'updated_by_id']) + + return CustomResponse( + general_message='Office Hours session deleted.' + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# Salt Mango Tree +# ───────────────────────────────────────────────────────────────────────────── + +class SaltMangoTreeListCreateAPI(PublicGetMixin, APIView): + """ + GET /media-content/salt-mango-tree/ — Public paginated list of episodes. + POST /media-content/salt-mango-tree/ — Create an episode (Admin only). + """ + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Media Content - Salt Mango Tree']) + def get(self, request): + qs = _base_qs(MediaContent.ContentType.SALT_MANGO_TREE) + qs = _apply_common_filters(qs, request, has_zone=True) + + paginated = CommonUtils.get_paginated_queryset( + qs, request, + search_fields=['title', 'campus', 'description'], + sort_fields={ + 'date': 'date', + 'campus': 'campus', + 'created_at': 'created_at', + }, + ) + + serializer = SaltMangoTreeReadSerializer(paginated['queryset'], many=True) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + @extend_schema(tags=['Media Content - Salt Mango Tree']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + serializer = SaltMangoTreeWriteSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + general_message='Invalid data.', + message=serializer.errors, + ).get_failure_response() + + data = serializer.validated_data + record = MediaContent.objects.create( + id=str(uuid.uuid4()), + created_by_id=user_id, + updated_by_id=user_id, + **data, + ) + + return CustomResponse( + general_message='Salt Mango Tree episode created successfully.', + response=SaltMangoTreeReadSerializer(record).data, + ).get_success_response() + + +class SaltMangoTreeDetailAPI(PublicGetMixin, APIView): + """ + GET /media-content/salt-mango-tree// + PATCH /media-content/salt-mango-tree// (Admin) + DELETE /media-content/salt-mango-tree// (Admin) + """ + authentication_classes = [CustomizePermission] + + def _get_record(self, record_id): + return MediaContent.objects.filter( + id=record_id, + content_type=MediaContent.ContentType.SALT_MANGO_TREE, + deleted_at__isnull=True, + ).first() + + @extend_schema(tags=['Media Content - Salt Mango Tree']) + def get(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Salt Mango Tree episode not found.' + ).get_failure_response() + + return CustomResponse( + general_message='Salt Mango Tree episode retrieved.', + response=SaltMangoTreeReadSerializer(record).data, + ).get_success_response() + + @extend_schema(tags=['Media Content - Salt Mango Tree']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def patch(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Salt Mango Tree episode not found.' + ).get_failure_response() + + serializer = SaltMangoTreeWriteSerializer(data=request.data, partial=True) + if not serializer.is_valid(): + return CustomResponse( + general_message='Invalid data.', + message=serializer.errors, + ).get_failure_response() + + user_id = JWTUtils.fetch_user_id(request) + data = serializer.validated_data + data.pop('content_type', None) + + for attr, value in data.items(): + setattr(record, attr, value) + record.updated_by_id = user_id + record.save() + + return CustomResponse( + general_message='Salt Mango Tree episode updated.', + response=SaltMangoTreeReadSerializer(record).data, + ).get_success_response() + + @extend_schema(tags=['Media Content - Salt Mango Tree']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def delete(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Salt Mango Tree episode not found.' + ).get_failure_response() + + record.deleted_at = timezone.now() + record.updated_by_id = JWTUtils.fetch_user_id(request) + record.save(update_fields=['deleted_at', 'updated_by_id']) + + return CustomResponse( + general_message='Salt Mango Tree episode deleted.' + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# Inspiration Station Radio +# ───────────────────────────────────────────────────────────────────────────── + +class InspirationStationListCreateAPI(PublicGetMixin, APIView): + """ + GET /media-content/inspiration-station/ — Public paginated list. + POST /media-content/inspiration-station/ — Create an episode (Admin only). + """ + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Media Content - Inspiration Station']) + def get(self, request): + qs = _base_qs(MediaContent.ContentType.INSPIRATION_STATION) + qs = _apply_common_filters(qs, request, has_zone=True) + + paginated = CommonUtils.get_paginated_queryset( + qs, request, + search_fields=['title', 'campus', 'description'], + sort_fields={ + 'date': 'date', + 'campus': 'campus', + 'created_at': 'created_at', + }, + ) + + serializer = InspirationStationReadSerializer(paginated['queryset'], many=True) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + @extend_schema(tags=['Media Content - Inspiration Station']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + serializer = InspirationStationWriteSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + general_message='Invalid data.', + message=serializer.errors, + ).get_failure_response() + + data = serializer.validated_data + record = MediaContent.objects.create( + id=str(uuid.uuid4()), + created_by_id=user_id, + updated_by_id=user_id, + **data, + ) + + return CustomResponse( + general_message='Inspiration Station episode created successfully.', + response=InspirationStationReadSerializer(record).data, + ).get_success_response() + + +class InspirationStationDetailAPI(PublicGetMixin, APIView): + """ + GET /media-content/inspiration-station// + PATCH /media-content/inspiration-station// (Admin) + DELETE /media-content/inspiration-station// (Admin) + """ + authentication_classes = [CustomizePermission] + + def _get_record(self, record_id): + return MediaContent.objects.filter( + id=record_id, + content_type=MediaContent.ContentType.INSPIRATION_STATION, + deleted_at__isnull=True, + ).first() + + @extend_schema(tags=['Media Content - Inspiration Station']) + def get(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Inspiration Station episode not found.' + ).get_failure_response() + + return CustomResponse( + general_message='Inspiration Station episode retrieved.', + response=InspirationStationReadSerializer(record).data, + ).get_success_response() + + @extend_schema(tags=['Media Content - Inspiration Station']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def patch(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Inspiration Station episode not found.' + ).get_failure_response() + + serializer = InspirationStationWriteSerializer(data=request.data, partial=True) + if not serializer.is_valid(): + return CustomResponse( + general_message='Invalid data.', + message=serializer.errors, + ).get_failure_response() + + user_id = JWTUtils.fetch_user_id(request) + data = serializer.validated_data + data.pop('content_type', None) + + for attr, value in data.items(): + setattr(record, attr, value) + record.updated_by_id = user_id + record.save() + + return CustomResponse( + general_message='Inspiration Station episode updated.', + response=InspirationStationReadSerializer(record).data, + ).get_success_response() + + @extend_schema(tags=['Media Content - Inspiration Station']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def delete(self, request, record_id): + record = self._get_record(record_id) + if not record: + return CustomResponse( + general_message='Inspiration Station episode not found.' + ).get_failure_response() + + record.deleted_at = timezone.now() + record.updated_by_id = JWTUtils.fetch_user_id(request) + record.save(update_fields=['deleted_at', 'updated_by_id']) + + return CustomResponse( + general_message='Inspiration Station episode deleted.' + ).get_success_response() + + +class MediaContentBulkImportAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Media Content - Import']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def post(self, request): + file = request.data.get('file') + if not file: + return CustomResponse( + general_message='File is required.', + ).get_failure_response() + + ALLOWED_CSV_MIME_TYPES = {'text/csv', 'application/csv', 'application/vnd.ms-excel'} + if file.content_type not in ALLOWED_CSV_MIME_TYPES: + return CustomResponse( + general_message='Invalid file type. Please upload a CSV file.', + ).get_failure_response() + + + try: + reader = csv.DictReader(codecs.iterdecode(file, 'utf-8-sig')) + rows = list(reader) # materialize here so decode errors are caught + except Exception: + return CustomResponse( + general_message='Error reading CSV file. Ensure it is UTF-8 encoded.', + ).get_failure_response() + + if not rows: + return CustomResponse( + general_message='CSV file is empty. No records to import.', + ).get_failure_response() + + user_id = JWTUtils.fetch_user_id(request) + success_count = 0 + failed_rows = [] + + for row_num, row in enumerate(rows, start=2): + content_type = row.get('content_type', '').strip() + row_data = dict(row) + + # Clean up empty values to avoid validation issues + row_data = {k: v for k, v in row_data.items() if v is not None and str(v).strip() != ''} + + if content_type == MediaContent.ContentType.OFFICE_HOURS: + # Normalize: CSV may use 'topic' instead of 'title' + if 'topic' in row_data and not row_data.get('title'): + row_data['title'] = row_data.pop('topic') + if 'interest_groups' in row_data: + row_data['interest_groups'] = [ + ig.strip() + for ig in str(row_data['interest_groups']).split(',') + if ig.strip() + ] + serializer = OfficeHoursWriteSerializer(data=row_data) + + elif content_type == MediaContent.ContentType.SALT_MANGO_TREE: + # Normalize: CSV may use 'title' instead of 'topic' + if 'title' in row_data and not row_data.get('topic'): + row_data['topic'] = row_data.pop('title') + serializer = SaltMangoTreeWriteSerializer(data=row_data) + + elif content_type == MediaContent.ContentType.INSPIRATION_STATION: + # Normalize: CSV may use 'title' instead of 'topic' + if 'title' in row_data and not row_data.get('topic'): + row_data['topic'] = row_data.pop('title') + serializer = InspirationStationWriteSerializer(data=row_data) + + else: + failed_rows.append({ + "row": row_num, + "title": row.get('title') or row.get('topic', ''), + "reason": ( + f"Invalid or missing content_type: '{content_type}'. " + "Must be 'office_hours', 'salt_mango_tree', or 'inspiration_station'." + ), + }) + continue + + if serializer.is_valid(): + data = serializer.validated_data + MediaContent.objects.create( + id=str(uuid.uuid4()), + created_by_id=user_id, + updated_by_id=user_id, + **data, + ) + success_count += 1 + else: + failed_rows.append({ + "row": row_num, + "title": row.get('title') or row.get('topic', ''), + "reason": serializer.errors, + }) + + return CustomResponse( + general_message='Bulk import completed.', + response={ + 'success_count': success_count, + 'failed_count': len(failed_rows), + 'failed_rows': failed_rows, + } + ).get_success_response() + + +class MediaContentBulkExportAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=['Media Content - Export']) + @RoleRequired([RoleType.ADMIN.value, RoleType.ASSOCIATE.value, RoleType.IG_LEAD.value]) + def get(self, request, content_type): + if content_type == MediaContent.ContentType.OFFICE_HOURS: + queryset = MediaContent.objects.filter( + content_type=MediaContent.ContentType.OFFICE_HOURS, + deleted_at__isnull=True, + ) + serializer = OfficeHoursReadSerializer(queryset, many=True, context={'request': request}) + elif content_type == MediaContent.ContentType.SALT_MANGO_TREE: + queryset = MediaContent.objects.filter( + content_type=MediaContent.ContentType.SALT_MANGO_TREE, + deleted_at__isnull=True, + ) + serializer = SaltMangoTreeReadSerializer(queryset, many=True) + elif content_type == MediaContent.ContentType.INSPIRATION_STATION: + queryset = MediaContent.objects.filter( + content_type=MediaContent.ContentType.INSPIRATION_STATION, + deleted_at__isnull=True, + ) + serializer = InspirationStationReadSerializer(queryset, many=True) + else: + return CustomResponse( + general_message='Invalid content type. Must be office_hours, salt_mango_tree, or inspiration_station.' + ).get_failure_response() + + return CommonUtils.generate_csv(serializer.data, f"{content_type}_export") + + + diff --git a/api/dashboard/mentor/availability_views.py b/api/dashboard/mentor/availability_views.py new file mode 100644 index 000000000..c7aa623cf --- /dev/null +++ b/api/dashboard/mentor/availability_views.py @@ -0,0 +1,164 @@ +from rest_framework.views import APIView +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.mentor import MentorAvailabilitySlot +from db.task import UserIgLink +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from . import serializers + +class MentorAvailabilitySlotAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Availability'], + description="List all availability slots for the logged-in mentor.", + parameters=[ + OpenApiParameter("ig_id", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("is_active", OpenApiTypes.BOOL, OpenApiParameter.QUERY, required=False), + ], + responses={200: serializers.AvailabilitySlotSerializer(many=True)}, + ) + @role_required([RoleType.MENTOR.value]) + def get(self, request, slot_id=None): + user_id = JWTUtils.fetch_user_id(request) + + if slot_id: + slot = MentorAvailabilitySlot.objects.filter(id=slot_id, mentor_user_id=user_id).first() + if not slot: + return CustomResponse( + general_message="Availability slot not found." + ).get_failure_response(status_code=404) + return CustomResponse(response=serializers.AvailabilitySlotSerializer(slot).data).get_success_response() + + slots = MentorAvailabilitySlot.objects.filter(mentor_user_id=user_id) + + ig_id = request.query_params.get("ig_id") + is_active = request.query_params.get("is_active") + + if ig_id: + slots = slots.filter(ig_id=ig_id) + if is_active is not None: + slots = slots.filter(is_active=is_active.lower() == 'true') + + paginated_queryset = CommonUtils.get_paginated_queryset( + slots, request, + search_fields=["ig__name"], + sort_fields={"weekday": "weekday", "start_time": "start_time", "created_at": "created_at"} + ) + + serializer = serializers.AvailabilitySlotSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + @extend_schema( + tags=['Dashboard - Mentor Availability'], + description="Create a new mentor availability slot.", + request=serializers.AvailabilitySlotCreateUpdateSerializer, + responses={200: serializers.AvailabilitySlotCreateUpdateSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + ig_id = request.data.get("ig") + + # Availability is a mentor-level setting. An IG is optional: when one is + # supplied we verify the mentor is actually assigned to it, but a slot + # with no IG (ig=NULL) is valid and applies across all the mentor's IGs. + if ig_id and not UserIgLink.objects.filter( + user_id=user_id, + ig_id=ig_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True + ).exists(): + return CustomResponse( + general_message="You are not assigned as a mentor for this Interest Group." + ).get_failure_response(status_code=403) + + serializer = serializers.AvailabilitySlotCreateUpdateSerializer( + data=request.data, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Availability slot created successfully.", + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + @extend_schema( + tags=['Dashboard - Mentor Availability'], + description="Update an existing mentor availability slot.", + request=serializers.AvailabilitySlotCreateUpdateSerializer, + responses={200: serializers.AvailabilitySlotCreateUpdateSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def patch(self, request, slot_id): + user_id = JWTUtils.fetch_user_id(request) + slot = MentorAvailabilitySlot.objects.filter(id=slot_id, mentor_user_id=user_id).first() + + if not slot: + return CustomResponse( + general_message="Availability slot not found." + ).get_failure_response(status_code=404) + + serializer = serializers.AvailabilitySlotCreateUpdateSerializer( + slot, data=request.data, partial=True, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Availability slot updated successfully.", + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + @extend_schema( + tags=['Dashboard - Mentor Availability'], + description="Delete a mentor availability slot.", + ) + @role_required([RoleType.MENTOR.value]) + def delete(self, request, slot_id): + user_id = JWTUtils.fetch_user_id(request) + slot = MentorAvailabilitySlot.objects.filter(id=slot_id, mentor_user_id=user_id).first() + + if not slot: + return CustomResponse( + general_message="Availability slot not found." + ).get_failure_response(status_code=404) + + slot.delete() + return CustomResponse( + general_message="Availability slot deleted successfully." + ).get_success_response() + +class MentorPublicAvailabilityAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Public'], + description="List active availability slots for a specific mentor.", + responses={200: serializers.AvailabilitySlotSerializer(many=True)}, + ) + def get(self, request, mentor_id): + from db.user import UserMentor + + mentor = UserMentor.objects.filter(id=mentor_id, status=UserMentor.Status.APPROVED).first() + if not mentor: + return CustomResponse( + general_message="Mentor not found or not approved." + ).get_failure_response(status_code=404) + + slots = MentorAvailabilitySlot.objects.filter(mentor_user_id=mentor.user_id, is_active=True) + serializer = serializers.AvailabilitySlotSerializer(slots, many=True) + + return CustomResponse(response=serializer.data).get_success_response() diff --git a/api/dashboard/mentor/dash_mentor_helper.py b/api/dashboard/mentor/dash_mentor_helper.py new file mode 100644 index 000000000..cb943fdf3 --- /dev/null +++ b/api/dashboard/mentor/dash_mentor_helper.py @@ -0,0 +1,445 @@ +from datetime import timedelta +from django.db import transaction +from django.utils import timezone +from django.core.cache import cache +from db.user import UserMentor, MentorScopeGrant +from db.organization import UserOrganizationLink +from db.task import InterestGroup, UserIgLink, TaskList, KarmaActivityLog +from db.mentor import MentorshipSession, IgOpportunity +from db.learning_circle import LearningCircle +from utils.utils import DateTimeUtils + + +CACHE_TTL = 15 * 60 # 15 minutes + + +def get_mentor_company(mentor): + """ + Resolve a mentor's employer title. + + A mentor's company is their identity, not a permission scope — it must + be visible regardless of which tier(s) they hold. Falls back from the + tier-scoped `UserMentor.org` (only ever set for COMPANY_MENTOR) to the + user's actual employment record, since IG-only mentors never get `org` set. + """ + if mentor.org: + return mentor.org.title + + org_link = UserOrganizationLink.objects.filter( + user=mentor.user, org__org_type="Company" + ).select_related("org").first() + return org_link.org.title if org_link else None + + +def get_mentor_scopes(user_id): + """ + Return the set of active (scope_type, scope_id) pairs this user holds + mentor authority over. This is the single deterministic source of truth + for permission checks — never query UserMentor.mentor_tier directly for + authorization, since a user can hold multiple tiers and grants are what + actually govern access. + """ + grants = MentorScopeGrant.objects.filter( + mentor__user_id=user_id, + mentor__status=UserMentor.Status.APPROVED, + is_active=True, + ).values_list('scope_type', 'scope_id') + + scopes = set(grants) + if scopes: + return scopes + + # Legacy fallback: grants may not exist yet for this mentor (e.g. before + # the alter-1.63 backfill ran, or a race with grant creation). + fallback = set() + for tier, org_id in UserMentor.objects.filter( + user_id=user_id, status=UserMentor.Status.APPROVED + ).values_list('mentor_tier', 'org_id'): + if tier == UserMentor.MentorTier.IG_MENTOR: + # org_id is always NULL on IG_MENTOR rows — IG authority lives + # per-IG in UserIgLink, not on the UserMentor row. Emit one scope + # per actively-mentored IG; a bare (IG_MENTOR, None) entry would + # be silently discarded by any caller filtering on a non-null + # scope_id (e.g. get_scope_ids), making the mentor appear to + # have no IG authority at all during the pre-backfill window. + ig_ids = UserIgLink.objects.filter( + user_id=user_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).values_list('ig_id', flat=True) + fallback.update((tier, str(ig_id)) for ig_id in ig_ids) + else: + fallback.add((tier, str(org_id) if org_id is not None else None)) + return fallback + + +def has_scope(user_id, scope_type, scope_id=None): + scope_id = str(scope_id) if scope_id is not None else None + return (scope_type, scope_id) in get_mentor_scopes(user_id) + + +def get_scope_ids(user_id, scope_type): + """All active scope_ids (org/ig ids) this user holds `scope_type` authority over.""" + return {sid for (st, sid) in get_mentor_scopes(user_id) if st == scope_type and sid} + + +def get_verified_company_for_mentor(user_id): + """ + Resolve the verified Company a user may act on behalf of: either they are + its registrant (company_user), or they hold an active COMPANY_MENTOR + grant scoped to its Organization. Single source of truth for the + previously-duplicated _get_company_for_user/get_verified_company helpers. + """ + from db.company import Company + from db.organization import Organization + from db.user import MentorScopeGrant + + company = Company.objects.filter(company_user_id=user_id, status="verified").first() + if company: + return company + + org_ids = get_scope_ids(user_id, MentorScopeGrant.ScopeType.COMPANY_MENTOR) + if not org_ids: + return None + org = Organization.objects.filter(id__in=org_ids).first() + if not org: + return None + return Company.objects.filter(name=org.title, status="verified").first() + + +@transaction.atomic +def reconcile_mentor_ig_grants(mentor, preferred_ig_ids, actor_user_id): + """ + Make the mentor's active IG_MENTOR MentorScopeGrant set exactly equal + the (valid) preferred_ig_ids — the MentorScopeGrant counterpart to + reconcile_mentor_ig_links. Only ever touches IG_MENTOR grants; any + Company/Campus grant this mentor holds is untouched (grants are additive + and independent per scope). + """ + desired = {str(i) for i in (preferred_ig_ids or []) if i} + if desired: + desired &= { + str(x) + for x in InterestGroup.objects.filter(id__in=desired).values_list( + "id", flat=True + ) + } + + ig_grants = list( + MentorScopeGrant.objects.filter( + mentor=mentor, scope_type=MentorScopeGrant.ScopeType.IG_MENTOR + ) + ) + current_active = {g.scope_id for g in ig_grants if g.is_active} + + to_add = desired - current_active + to_remove = current_active - desired + now = DateTimeUtils.get_current_utc_time() + + for ig_id in to_add: + existing = next((g for g in ig_grants if g.scope_id == ig_id), None) + if existing: + existing.is_active = True + existing.revoked_by = None + existing.revoked_at = None + existing.save(update_fields=["is_active", "revoked_by", "revoked_at"]) + else: + MentorScopeGrant.objects.create( + mentor=mentor, + scope_type=MentorScopeGrant.ScopeType.IG_MENTOR, + scope_id=ig_id, + is_active=True, + granted_by_id=actor_user_id, + granted_at=now, + ) + + if to_remove: + MentorScopeGrant.objects.filter( + mentor=mentor, + scope_type=MentorScopeGrant.ScopeType.IG_MENTOR, + scope_id__in=to_remove, + ).update(is_active=False, revoked_by_id=actor_user_id, revoked_at=now) + + +@transaction.atomic +def reconcile_mentor_ig_links(user, preferred_ig_ids, actor_user_id): + """ + Make the user's ACTIVE MENTOR UserIgLink set exactly equal the (valid) + preferred_ig_ids: adds/reactivates links for newly chosen IGs and + deactivates links for removed IGs. + + Only ever touches assignment_type=MENTOR rows — LEARNER/LEAD/MODERATOR + links for the same IG are never modified. Used both for first-time admin + approval and for self-service edits by already-approved IG mentors. + + Returns (added_ig_ids, removed_ig_ids) as sets of str. + """ + desired = {str(i) for i in (preferred_ig_ids or []) if i} + if desired: + desired &= { + str(x) + for x in InterestGroup.objects.filter(id__in=desired).values_list( + "id", flat=True + ) + } + + mentor_links = list( + UserIgLink.objects.filter( + user=user, assignment_type=UserIgLink.AssignmentType.MENTOR + ) + ) + current_active = {str(link.ig_id) for link in mentor_links if link.is_active} + + to_add = desired - current_active + to_remove = current_active - desired + now = DateTimeUtils.get_current_utc_time() + + for ig_id in to_add: + existing = next((l for l in mentor_links if str(l.ig_id) == ig_id), None) + if existing: + existing.is_active = True + existing.unassigned_at = None + existing.assigned_by_id = actor_user_id + existing.save( + update_fields=["is_active", "unassigned_at", "assigned_by"] + ) + else: + UserIgLink.objects.create( + user=user, + ig_id=ig_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + assigned_by_id=actor_user_id, + created_by_id=actor_user_id, + ) + + if to_remove: + UserIgLink.objects.filter( + user=user, + assignment_type=UserIgLink.AssignmentType.MENTOR, + ig_id__in=to_remove, + is_active=True, + ).update(is_active=False, unassigned_at=now) + + return to_add, to_remove + +def get_mentor_overview(user_id): + """ + Dynamically aggregates metrics based on the authenticated mentor's scope, + derived entirely from their UserMentor record and UserIgLink. + """ + active_scopes = [] + + # 1. Campus and Company Scopes from UserMentor + user_mentors = UserMentor.objects.filter(user_id=user_id, status=UserMentor.Status.APPROVED).select_related('org') + for mentor in user_mentors: + if mentor.mentor_tier == UserMentor.MentorTier.CAMPUS_MENTOR and mentor.org_id: + active_scopes.append({ + "scope_type": "CAMPUS_MENTOR", + "scope_id": mentor.org_id, + "scope_name": mentor.org.title + }) + elif mentor.mentor_tier == UserMentor.MentorTier.COMPANY_MENTOR and mentor.org_id: + active_scopes.append({ + "scope_type": "COMPANY_MENTOR", + "scope_id": mentor.org_id, + "scope_name": mentor.org.title + }) + + # 2. IG Scopes from UserIgLink + ig_links = UserIgLink.objects.filter( + user_id=user_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True + ).select_related('ig') + + for link in ig_links: + active_scopes.append({ + "scope_type": "IG_MENTOR", + "scope_id": link.ig_id, + "scope_name": link.ig.name if link.ig else None + }) + + # Assemble metrics via Cache or Computation + response_scopes = [] + for scope in active_scopes: + cache_key = f"mentor_dash_scope:{scope['scope_type']}:{scope['scope_id']}" + + metrics = None + try: + metrics = cache.get(cache_key) + except Exception: + pass + + if metrics is None: + if scope["scope_type"] == "CAMPUS_MENTOR": + metrics = _compute_campus_metrics(scope["scope_id"]) + elif scope["scope_type"] == "COMPANY_MENTOR": + metrics = _compute_company_metrics(scope["scope_id"]) + elif scope["scope_type"] == "IG_MENTOR": + metrics = _compute_ig_metrics(scope["scope_id"]) + + if metrics is not None: + try: + cache.set(cache_key, metrics, CACHE_TTL) + except Exception: + pass + + scope["metrics"] = metrics or {} + response_scopes.append(scope) + + return response_scopes + +def _compute_campus_metrics(org_id): + thirty_days_ago = timezone.now() - timedelta(days=30) + + total_learners = UserOrganizationLink.objects.filter( + org_id=org_id, verified=True + ).count() + + active_learners = UserOrganizationLink.objects.filter( + org_id=org_id, + verified=True, + user__wallet_user__karma_last_updated_at__gte=thirty_days_ago + ).count() + + inactive_learners = total_learners - active_learners + + campus_learning_circles = LearningCircle.objects.filter(org_id=org_id).count() + + pending_task_reviews = KarmaActivityLog.objects.filter( + user__user_organization_link_user__org_id=org_id, + mentor_review_status='PENDING' + ).count() + + campus_tasks = TaskList.objects.filter(org_id=org_id, active=True).count() + + upcoming_sessions = MentorshipSession.objects.filter( + entity_id=org_id, + session_type=MentorshipSession.SessionType.CAMPUS_SESSION, + starts_at__gt=timezone.now(), + is_deleted=False + ).exclude(status__in=[MentorshipSession.Status.CANCELLED, MentorshipSession.Status.REJECTED]).count() + + completed_sessions = MentorshipSession.objects.filter( + entity_id=org_id, + session_type=MentorshipSession.SessionType.CAMPUS_SESSION, + status=MentorshipSession.Status.COMPLETED, + is_deleted=False + ).count() + + return { + "total_learners": total_learners, + "active_learners": active_learners, + "inactive_learners": inactive_learners, + "upcoming_sessions": upcoming_sessions, + "completed_sessions": completed_sessions, + "campus_learning_circles": campus_learning_circles, + "pending_task_reviews": pending_task_reviews, + "campus_tasks": campus_tasks + } + +def _compute_company_metrics(org_id): + thirty_days_ago = timezone.now() - timedelta(days=30) + + total_assigned_learners = UserOrganizationLink.objects.filter(org_id=org_id).count() + + active_learners = UserOrganizationLink.objects.filter( + org_id=org_id, + user__wallet_user__karma_last_updated_at__gte=thirty_days_ago + ).count() + + inactive_learners = total_assigned_learners - active_learners + + gigs_tasks = TaskList.objects.filter(org_id=org_id, active=True).count() + + pending_appraisals = KarmaActivityLog.objects.filter( + task__org_id=org_id, + mentor_review_status='PENDING' + ).count() + + completed_appraisals = KarmaActivityLog.objects.filter( + task__org_id=org_id, + mentor_review_status__in=['APPROVED', 'REJECTED'] + ).count() + + upcoming_sessions = MentorshipSession.objects.filter( + entity_id=org_id, + session_type=MentorshipSession.SessionType.COMPANY_SESSION, + starts_at__gt=timezone.now(), + is_deleted=False + ).exclude(status__in=[MentorshipSession.Status.CANCELLED, MentorshipSession.Status.REJECTED]).count() + + completed_sessions = MentorshipSession.objects.filter( + entity_id=org_id, + session_type=MentorshipSession.SessionType.COMPANY_SESSION, + status=MentorshipSession.Status.COMPLETED, + is_deleted=False + ).count() + + open_opportunities = IgOpportunity.objects.filter(org_id=org_id, status=IgOpportunity.Status.PUBLISHED).count() + + return { + "total_assigned_learners": total_assigned_learners, + "active_learners": active_learners, + "inactive_learners": inactive_learners, + "gigs_tasks": gigs_tasks, + "pending_appraisals": pending_appraisals, + "completed_appraisals": completed_appraisals, + "upcoming_sessions": upcoming_sessions, + "completed_sessions": completed_sessions, + "open_opportunities": open_opportunities + } + +def _compute_ig_metrics(ig_id): + thirty_days_ago = timezone.now() - timedelta(days=30) + + total_ig_learners = UserIgLink.objects.filter( + ig_id=ig_id, + assignment_type=UserIgLink.AssignmentType.LEARNER, + is_active=True + ).count() + + active_ig_learners = UserIgLink.objects.filter( + ig_id=ig_id, + assignment_type=UserIgLink.AssignmentType.LEARNER, + is_active=True, + user__wallet_user__karma_last_updated_at__gte=thirty_days_ago + ).count() + + inactive_ig_learners = total_ig_learners - active_ig_learners + + ig_learning_circles = LearningCircle.objects.filter(ig_id=ig_id).count() + open_opportunities = IgOpportunity.objects.filter(ig_id=ig_id, status=IgOpportunity.Status.PUBLISHED).count() + ig_tasks = TaskList.objects.filter(ig_id=ig_id, active=True).count() + + upcoming_sessions = MentorshipSession.objects.filter( + entity_id=ig_id, + session_type=MentorshipSession.SessionType.IG_SESSION, + starts_at__gt=timezone.now(), + is_deleted=False + ).exclude(status__in=[MentorshipSession.Status.CANCELLED, MentorshipSession.Status.REJECTED]).count() + + completed_sessions = MentorshipSession.objects.filter( + entity_id=ig_id, + session_type=MentorshipSession.SessionType.IG_SESSION, + status=MentorshipSession.Status.COMPLETED, + is_deleted=False + ).count() + + pending_tasks = KarmaActivityLog.objects.filter( + task__ig_id=ig_id, + mentor_review_status='PENDING' + ).count() + + return { + "total_ig_learners": total_ig_learners, + "active_ig_learners": active_ig_learners, + "inactive_ig_learners": inactive_ig_learners, + "upcoming_sessions": upcoming_sessions, + "completed_sessions": completed_sessions, + "pending_tasks": pending_tasks, + "ig_learning_circles": ig_learning_circles, + "open_opportunities": open_opportunities, + "ig_tasks": ig_tasks + } diff --git a/api/dashboard/mentor/mentor_views.py b/api/dashboard/mentor/mentor_views.py new file mode 100644 index 000000000..f9ca18e8b --- /dev/null +++ b/api/dashboard/mentor/mentor_views.py @@ -0,0 +1,637 @@ +from rest_framework.views import APIView +from django.db.models import Q +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.user import UserMentor +from db.mentor import MentorshipSession +from db.task import KarmaActivityLog +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes, inline_serializer +from rest_framework import serializers as rest_serializers +from . import serializers +from .dash_mentor_helper import get_mentor_overview + + +class MentorRegistrationAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Submit a new mentor registration.", + request=serializers.MentorRegisterSerializer, + responses={200: serializers.MentorRegisterSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + + if UserMentor.objects.filter(user_id=user_id).exists(): + return CustomResponse( + general_message="A mentor request already exists for your account." + ).get_failure_response() + + serializer = serializers.MentorRegisterSerializer( + data=request.data, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Mentor registration submitted successfully.", + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Update a pending mentor application or resubmit a rejected one.", + request=serializers.MentorUpdateSerializer, + responses={200: serializers.MentorUpdateSerializer}, + ) + def patch(self, request): + user_id = JWTUtils.fetch_user_id(request) + mentor = UserMentor.objects.filter(user_id=user_id).first() + + if not mentor: + return CustomResponse( + general_message="No mentor registration request found for your account." + ).get_failure_response(status_code=404) + + if mentor.status == UserMentor.Status.APPROVED: + return CustomResponse( + general_message="Your mentor application is already approved. Please use the profile endpoint to update your details." + ).get_failure_response() + + serializer = serializers.MentorUpdateSerializer( + mentor, data=request.data, partial=True, context={"user_id": user_id} + ) + + if serializer.is_valid(): + if mentor.status == UserMentor.Status.REJECTED: + serializer.save(status=UserMentor.Status.PENDING, verification_note=None) + msg = "Mentor registration updated and resubmitted successfully." + else: + serializer.save() + msg = "Mentor application updated successfully." + + return CustomResponse( + general_message=msg, + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class MentorStatusAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Check the status of a mentor registration.", + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + mentor = UserMentor.objects.filter(user_id=user_id).first() + if not mentor: + return CustomResponse( + general_message="No mentor request found for your account." + ).get_failure_response(status_code=404) + + from .dash_mentor_helper import get_mentor_company + organization = get_mentor_company(mentor) + + return CustomResponse( + response={ + "status": mentor.status, + "organization": organization, + "verified_by": getattr(mentor.verified_by, "full_name", None) if mentor.verified_by else None, + "verified_at": mentor.verified_at, + } + ).get_success_response() + +class MentorActivityListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Get recent activity of the currently logged-in mentor (sessions created, tasks appraised).", + responses={200: serializers.MentorActivitySerializer(many=True)}, + ) + @role_required([RoleType.MENTOR.value, RoleType.CAMPUS_LEAD.value, RoleType.LEAD_ENABLER.value]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + # 1. Fetch MentorshipSessions created by this mentor + sessions = MentorshipSession.objects.filter( + created_by_id=user_id, + is_deleted=False + ) + + # 2. Fetch KarmaActivityLogs appraised by this mentor + appraisals = KarmaActivityLog.objects.filter( + appraiser_approved_by_id=user_id + ).select_related("task") + + activities = [] + for session in sessions: + activities.append({ + "id": session.id, + "activity_type": "SESSION_CREATED", + "title": session.title, + "description": session.description, + "date": session.created_at, + "status": session.status, + }) + + for log in appraisals: + status_text = "Pending" + if log.appraiser_approved: + status_text = "Approved" + elif log.appraiser_approved is False: + status_text = "Rejected" + + activities.append({ + "id": str(log.id), + "activity_type": "TASK_APPRAISED", + "title": log.task.title if log.task else "Unknown Task", + "description": None, + "date": log.updated_at, + "status": status_text, + }) + + # Sort activities by date descending + activities.sort(key=lambda x: x["date"] or x.get("created_at") or "", reverse=True) + + # Paginate the combined list + paginated_queryset = CommonUtils.get_paginated_queryset( + activities, request, + search_fields=["title", "activity_type", "status"], + sort_fields={"date": "date"} + ) + + serializer = serializers.MentorActivitySerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + +class MentorProfileAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Retrieve the profile of a verified mentor.", + responses={200: serializers.MentorDetailSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + mentor = UserMentor.objects.filter(user_id=user_id, status=UserMentor.Status.APPROVED).first() + + if not mentor: + return CustomResponse( + general_message="Mentor profile not found or not approved." + ).get_failure_response(status_code=404) + + serializer = serializers.MentorDetailSerializer(mentor) + return CustomResponse(response=serializer.data).get_success_response() + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Update the profile of a verified mentor.", + request=serializers.MentorUpdateSerializer, + responses={200: serializers.MentorUpdateSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def patch(self, request): + user_id = JWTUtils.fetch_user_id(request) + mentor = UserMentor.objects.filter(user_id=user_id, status=UserMentor.Status.APPROVED).first() + + if not mentor: + return CustomResponse( + general_message="Mentor profile not found or not approved." + ).get_failure_response(status_code=404) + + serializer = serializers.MentorUpdateSerializer( + mentor, data=request.data, partial=True, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Mentor profile updated successfully.", + response=serializers.MentorDetailSerializer(mentor).data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class MentorListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="List all mentor applications with filtering.", + parameters=[ + OpenApiParameter("status", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("mentor_tier", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + ], + responses={200: serializers.MentorListSerializer(many=True)}, + ) + @role_required([RoleType.ADMIN.value]) + def get(self, request): + mentors = UserMentor.objects.all() + + status = request.query_params.get("status") + mentor_tier = request.query_params.get("mentor_tier") + + if status: + mentors = mentors.filter(status=status) + if mentor_tier: + mentors = mentors.filter(mentor_tier=mentor_tier) + + paginated_queryset = CommonUtils.get_paginated_queryset( + mentors, request, + search_fields=["user__full_name", "user__email"], + sort_fields={"created_at": "created_at", "status": "status", "user_full_name": "user__full_name"} + ) + + serializer = serializers.MentorListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + +class MentorDetailAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Get details of a specific mentor by ID.", + responses={200: serializers.MentorDetailSerializer}, + ) + @role_required([RoleType.ADMIN.value]) + def get(self, request, mentor_id): + mentor = UserMentor.objects.filter(id=mentor_id).first() + if not mentor: + return CustomResponse( + general_message="Mentor not found." + ).get_failure_response(status_code=404) + + serializer = serializers.MentorDetailSerializer(mentor) + return CustomResponse(response=serializer.data).get_success_response() + +def _is_company_owner_of(actor_id, mentor): + """ + True if `actor_id` owns the verified Company that `mentor`'s + COMPANY_MENTOR application is scoped to. Verification authority for a + company's own mentor applications belongs to that company's owner, not + only platform admins. + """ + if mentor.mentor_tier != UserMentor.MentorTier.COMPANY_MENTOR or not mentor.org: + return False + + from db.company import Company + return Company.objects.filter( + company_user_id=actor_id, + status="verified", + name=mentor.org.title, + ).exists() + + +class MentorVerifyAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description=( + "Verify or reject a mentor application. Platform admins can " + "verify any application; a Company's owner can verify " + "COMPANY_MENTOR applications scoped to their own company." + ), + request=serializers.MentorVerifySerializer, + ) + def patch(self, request, mentor_id): + user_id = JWTUtils.fetch_user_id(request) + mentor = UserMentor.objects.filter(id=mentor_id).first() + + if not mentor: + return CustomResponse( + general_message="Mentor request not found." + ).get_failure_response(status_code=404) + + roles = JWTUtils.fetch_role(request) + is_admin = RoleType.ADMIN.value in roles + if not is_admin and not _is_company_owner_of(user_id, mentor): + return CustomResponse( + general_message="You are not authorized to verify this mentor application." + ).get_failure_response(status_code=403) + + if mentor.status == UserMentor.Status.APPROVED: + return CustomResponse( + general_message="Mentor is already approved." + ).get_failure_response() + + serializer = serializers.MentorVerifySerializer( + mentor, data=request.data, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message=f"Mentor status updated to {serializer.validated_data.get('status')} successfully." + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class MentorPublicProfileAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Public'], + description="View a mentor's profile publicly.", + responses={200: serializers.MentorDetailSerializer}, + ) + def get(self, request, mentor_id): + mentor = UserMentor.objects.filter(id=mentor_id, status=UserMentor.Status.APPROVED).first() + + if not mentor: + return CustomResponse( + general_message="Mentor profile not found or not approved." + ).get_failure_response(status_code=404) + + serializer = serializers.MentorDetailSerializer(mentor) + return CustomResponse(response=serializer.data).get_success_response() + +class MentorOverviewAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Retrieve an overview dashboard of metrics aggregated dynamically based on the authenticated mentor's active scopes (Campus, Company, IG).", + responses={ + 200: inline_serializer( + name='MentorOverviewData', + fields={ + 'scopes': inline_serializer( + name='MentorScopeMetrics', + fields={ + 'scope_type': rest_serializers.CharField(), + 'scope_id': rest_serializers.CharField(), + 'scope_name': rest_serializers.CharField(allow_null=True), + 'metrics': rest_serializers.DictField() + }, + many=True + ) + } + ) + } + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + scopes = get_mentor_overview(user_id) + + if not scopes: + return CustomResponse( + general_message="No active mentor scopes found for this user." + ).get_failure_response(status_code=403) + + return CustomResponse( + general_message="Mentor dashboard fetched successfully.", + response={"scopes": scopes} + ).get_success_response() + + +class AdminAssignMentorAPI(APIView): + """ + Admin-only endpoint for bulk assigning / revoking mentor status. + + POST /api/v1/mentor/admin/assign/ + Body: { user_muids, mentor_tier, [org_id], [ig_ids], [about], [expertise], [hours] } + Assigns all listed users as mentors atomically. + + DELETE /api/v1/mentor/admin/assign// + Revokes mentor assignment for the given user. + Optional query param ?mentor_tier= scopes revocation to a single tier. + """ + permission_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=["Dashboard - Mentor"], + description="Bulk assign users as mentors for a specific tier (admin only).", + request=serializers.AdminAssignMentorSerializer, + responses={200: None}, + ) + def post(self, request): + admin_id = JWTUtils.fetch_user_id(request) + + ser = serializers.AdminAssignMentorSerializer( + data=request.data, + context={"user_id": admin_id}, + ) + if not ser.is_valid(): + return CustomResponse(message=ser.errors).get_failure_response() + + assigned_muids = ser.save() + return CustomResponse( + general_message="Mentors assigned successfully.", + response={"assigned_user_muids": assigned_muids}, + ).get_success_response() + + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=["Dashboard - Mentor"], + description=( + "Revoke mentor assignment for a user (admin only). " + "Supply ?mentor_tier= to target a single tier; " + "omit it to revoke all tiers." + ), + parameters=[ + OpenApiParameter( + name="mentor_tier", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=False, + description="Optional tier to restrict revocation scope.", + ) + ], + responses={200: None}, + ) + def delete(self, request, user_muid): + from db.user import User, UserRoleLink, Role + from db.task import UserIgLink + from utils.types import RoleType as _RoleType + from django.db import transaction + + admin_id = JWTUtils.fetch_user_id(request) + + # Resolve user + user = User.objects.filter(muid=user_muid).first() + if not user: + return CustomResponse( + general_message=f"No user found with muid '{user_muid}'." + ).get_failure_response(status_code=404) + + mentor_tier = request.query_params.get("mentor_tier") + + # Build the queryset of UserMentor records to revoke + qs = UserMentor.objects.filter(user=user, status=UserMentor.Status.APPROVED) + if mentor_tier: + qs = qs.filter(mentor_tier=mentor_tier) + + records = list(qs) + if not records: + return CustomResponse( + general_message="No approved mentor records found to revoke." + ).get_failure_response(status_code=404) + + now = None + try: + from utils.utils import DateTimeUtils as _DTU + now = _DTU.get_current_utc_time() + except Exception: + from django.utils import timezone + now = timezone.now() + + with transaction.atomic(): + for record in records: + record.status = UserMentor.Status.REJECTED + record.updated_by_id = admin_id + record.updated_at = now + record.save(update_fields=["status", "updated_by_id", "updated_at"]) + + # Deactivate IG links for IG_MENTOR + if record.mentor_tier == UserMentor.MentorTier.IG_MENTOR: + UserIgLink.objects.filter( + user=user, + assignment_type=UserIgLink.AssignmentType.MENTOR, + ).update(is_active=False) + + # Deactivate matching scope grants. NOTE: revoking mentor + # authority must never touch UserOrganizationLink — that's + # the user's employment/identity record, not a permission. + from db.user import MentorScopeGrant + MentorScopeGrant.objects.filter( + mentor=record, is_active=True + ).update( + is_active=False, + revoked_by_id=admin_id, + revoked_at=now, + ) + + # Strip the Mentor role only if no approved mentor records remain at all + remaining_approved = UserMentor.objects.filter( + user=user, status=UserMentor.Status.APPROVED + ).exists() + if not remaining_approved: + mentor_role = Role.objects.filter(title=_RoleType.MENTOR.value).first() + if mentor_role: + UserRoleLink.objects.filter(user=user, role=mentor_role).delete() + + return CustomResponse( + general_message="Mentor assignment revoked successfully." + ).get_success_response() + + +class MentorScopeGrantListAPI(APIView): + """ + GET /mentor//grants/ — list all scope grants for a mentor. + Admins see any mentor; a Company owner sees only their own employees' + grants. + """ + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="List all scope grants for a mentor.", + responses={200: serializers.MentorScopeGrantSerializer(many=True)}, + ) + def get(self, request, mentor_id): + from db.user import MentorScopeGrant + + actor_id = JWTUtils.fetch_user_id(request) + mentor = UserMentor.objects.filter(id=mentor_id).first() + if not mentor: + return CustomResponse( + general_message="Mentor not found." + ).get_failure_response(status_code=404) + + roles = JWTUtils.fetch_role(request) + is_admin = RoleType.ADMIN.value in roles + if not is_admin and not _is_company_owner_of(actor_id, mentor): + return CustomResponse( + general_message="You are not authorized to view this mentor's grants." + ).get_failure_response(status_code=403) + + grants = MentorScopeGrant.objects.filter(mentor=mentor).order_by('-granted_at') + serializer = serializers.MentorScopeGrantSerializer(grants, many=True) + return CustomResponse(response=serializer.data).get_success_response() + + +class MentorScopeGrantRevokeAPI(APIView): + """ + DELETE /mentor//grants// — revoke a single scope + grant. Only ever deactivates that grant; every other grant this mentor + holds, and their UserOrganizationLink employment record, are untouched. + """ + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor'], + description="Revoke a single mentor scope grant.", + responses={200: None}, + ) + def delete(self, request, mentor_id, grant_id): + from db.user import MentorScopeGrant + + actor_id = JWTUtils.fetch_user_id(request) + mentor = UserMentor.objects.filter(id=mentor_id).first() + if not mentor: + return CustomResponse( + general_message="Mentor not found." + ).get_failure_response(status_code=404) + + roles = JWTUtils.fetch_role(request) + is_admin = RoleType.ADMIN.value in roles + if not is_admin and not _is_company_owner_of(actor_id, mentor): + return CustomResponse( + general_message="You are not authorized to revoke this mentor's grants." + ).get_failure_response(status_code=403) + + grant = MentorScopeGrant.objects.filter(id=grant_id, mentor=mentor, is_active=True).first() + if not grant: + return CustomResponse( + general_message="Active grant not found." + ).get_failure_response(status_code=404) + + from utils.utils import DateTimeUtils + grant.is_active = False + grant.revoked_by_id = actor_id + grant.revoked_at = DateTimeUtils.get_current_utc_time() + grant.save(update_fields=["is_active", "revoked_by_id", "revoked_at"]) + + # IG_MENTOR grants are the display/audit counterpart of UserIgLink, + # the table session/task/availability endpoints actually check — + # keep them in sync so a surgical single-IG revoke actually removes + # that IG's mentoring capability, not just the audit-trail row. + if grant.scope_type == MentorScopeGrant.ScopeType.IG_MENTOR and grant.scope_id: + from db.task import UserIgLink + UserIgLink.objects.filter( + user=mentor.user, + ig_id=grant.scope_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + ).update(is_active=False) + + # If this was the mentor's last active grant for its tier and no + # other tier grant remains, the platform-wide Mentor role stays — + # that's governed by whether any UserMentor row is still APPROVED, + # which this grant revocation does not change. + + return CustomResponse( + general_message="Grant revoked successfully." + ).get_success_response() diff --git a/api/dashboard/mentor/participant_views.py b/api/dashboard/mentor/participant_views.py new file mode 100644 index 000000000..56ab9cded --- /dev/null +++ b/api/dashboard/mentor/participant_views.py @@ -0,0 +1,201 @@ +from rest_framework.views import APIView +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.mentor import MentorshipSession, MentorshipSessionUserLink +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from . import serializers + +class SessionJoinAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session Participant'], + description="Allows a MuLearn user to join a scheduled mentorship session.", + request=serializers.ParticipantJoinSerializer, + responses={200: serializers.ParticipantListSerializer}, + ) + def post(self, request, session_id): + user_id = JWTUtils.fetch_user_id(request) + + serializer = serializers.ParticipantJoinSerializer( + data=request.data, context={"user_id": user_id, "session_id": session_id} + ) + + if serializer.is_valid(): + link = serializer.save() + return CustomResponse( + general_message="Successfully joined the session.", + response=serializers.ParticipantListSerializer(link).data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + +class MentorAddParticipantAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session Participant'], + description="Mentor view to manually add a participant to a specific session using muid.", + request=serializers.MentorAddParticipantSerializer, + responses={200: serializers.ParticipantListSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def post(self, request, session_id): + user_id = JWTUtils.fetch_user_id(request) + + serializer = serializers.MentorAddParticipantSerializer( + data=request.data, context={"user_id": user_id, "session_id": session_id} + ) + + if serializer.is_valid(): + link = serializer.save() + return CustomResponse( + general_message="Successfully added participant to the session.", + response=serializers.ParticipantListSerializer(link).data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + + +class UserSessionHistoryAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session Participant'], + description="List all mentorship sessions the logged-in user has joined.", + responses={200: serializers.ParticipantListSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + links = MentorshipSessionUserLink.objects.filter(user_id=user_id).select_related('session', 'user') + + paginated_queryset = CommonUtils.get_paginated_queryset( + links, request, + search_fields=["session__title", "participant_role"], + sort_fields={"created_at": "created_at"} + ) + + page = list(paginated_queryset.get("queryset")) + ig_map = serializers.ParticipantListSerializer.build_ig_map(page) + serializer = serializers.ParticipantListSerializer( + page, many=True, context={"ig_map": ig_map} + ) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class MentorParticipantListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session Participant'], + description="Mentor view to list all participants in a specific session.", + responses={200: serializers.ParticipantListSerializer(many=True)}, + ) + @role_required([RoleType.MENTOR.value]) + def get(self, request, session_id): + user_id = JWTUtils.fetch_user_id(request) + + # Verify the logged-in mentor created this session + if not MentorshipSession.objects.filter(id=session_id, created_by_id=user_id).exists(): + return CustomResponse( + general_message="You don't have permission to view participants for this session." + ).get_failure_response(status_code=403) + + links = MentorshipSessionUserLink.objects.filter(session_id=session_id).select_related('user', 'session') + + paginated_queryset = CommonUtils.get_paginated_queryset( + links, request, + search_fields=["user__full_name", "user__muid"], + sort_fields={"created_at": "created_at", "user_full_name": "user__full_name"} + ) + + page = list(paginated_queryset.get("queryset")) + ig_map = serializers.ParticipantListSerializer.build_ig_map(page) + serializer = serializers.ParticipantListSerializer( + page, many=True, context={"ig_map": ig_map} + ) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class MentorParticipantUpdateAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session Participant'], + description="Mentor view to update participant's attendance and progress.", + request=serializers.ParticipantUpdateSerializer, + responses={200: serializers.ParticipantUpdateSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def patch(self, request, link_id): + user_id = JWTUtils.fetch_user_id(request) + + link = MentorshipSessionUserLink.objects.filter(id=link_id).select_related('session').first() + if not link: + return CustomResponse( + general_message="Participant record not found." + ).get_failure_response(status_code=404) + + # Verify the logged-in mentor created this session + if link.session.created_by_id != user_id: + return CustomResponse( + general_message="You don't have permission to update participants for this session." + ).get_failure_response(status_code=403) + + serializer = serializers.ParticipantUpdateSerializer( + link, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Participant record updated successfully.", + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class ParticipantFeedbackAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session Participant'], + description="Allows a user to submit feedback for a session they attended.", + request=serializers.ParticipantFeedbackSerializer, + responses={200: serializers.ParticipantListSerializer}, + ) + def patch(self, request, session_id): + user_id = JWTUtils.fetch_user_id(request) + + link = MentorshipSessionUserLink.objects.filter(session_id=session_id, user_id=user_id).first() + if not link: + return CustomResponse( + general_message="You are not a participant of this session." + ).get_failure_response(status_code=404) + + serializer = serializers.ParticipantFeedbackSerializer( + link, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Feedback submitted successfully.", + response=serializers.ParticipantListSerializer(link).data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() diff --git a/api/dashboard/mentor/serializers.py b/api/dashboard/mentor/serializers.py new file mode 100644 index 000000000..54158e34d --- /dev/null +++ b/api/dashboard/mentor/serializers.py @@ -0,0 +1,1352 @@ +import uuid +from rest_framework import serializers + +from db.user import UserMentor, UserRoleLink, Role, MentorScopeGrant + + +class MentorScopeGrantSerializer(serializers.ModelSerializer): + granted_by_name = serializers.CharField(source='granted_by.full_name', read_only=True) + revoked_by_name = serializers.CharField(source='revoked_by.full_name', read_only=True, default=None) + + class Meta: + model = MentorScopeGrant + fields = [ + "id", + "scope_type", + "scope_id", + "is_active", + "granted_by_name", + "granted_at", + "revoked_by_name", + "revoked_at", + ] +from db.task import InterestGroup, UserIgLink +from utils.types import RoleType +from utils.utils import DateTimeUtils +from django.db import transaction +from django.db.models import Q + +class MentorRegisterSerializer(serializers.ModelSerializer): + class Meta: + model = UserMentor + fields = [ + "about", + "expertise", + "reason", + "hours", + "preferred_ig_ids" + ] + + def validate_preferred_ig_ids(self, value): + if not value or not isinstance(value, list) or len(value) == 0: + raise serializers.ValidationError("At least one preferred IG ID must be provided.") + for ig_id in value: + if not InterestGroup.objects.filter(id=ig_id).exists(): + raise serializers.ValidationError(f"Invalid IG ID: {ig_id}") + return value + + def create(self, validated_data): + user_id = self.context["user_id"] + + mentor = UserMentor.objects.create( + user_id=user_id, + status=UserMentor.Status.PENDING, + mentor_tier=UserMentor.MentorTier.IG_MENTOR, + created_by_id=user_id, + updated_by_id=user_id, + created_at=DateTimeUtils.get_current_utc_time(), + updated_at=DateTimeUtils.get_current_utc_time(), + **validated_data + ) + return mentor + +class MentorUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = UserMentor + fields = [ + "about", + "expertise", + "reason", + "hours", + "preferred_ig_ids" + ] + + def validate_preferred_ig_ids(self, value): + if value: + if not isinstance(value, list) or len(value) == 0: + raise serializers.ValidationError("At least one preferred IG ID must be provided.") + for ig_id in value: + if not InterestGroup.objects.filter(id=ig_id).exists(): + raise serializers.ValidationError(f"Invalid IG ID: {ig_id}") + return value + + def validate(self, data): + # Every mentor must mentor at least one Interest Group (regardless of + # tier or any org scope) — the IG list cannot be cleared to empty. + instance = self.instance + if instance and "preferred_ig_ids" in data and not data["preferred_ig_ids"]: + raise serializers.ValidationError( + {"preferred_ig_ids": "You must mentor at least one Interest Group."} + ) + return data + + def update(self, instance, validated_data): + validated_data['updated_at'] = DateTimeUtils.get_current_utc_time() + validated_data['updated_by_id'] = self.context.get("user_id", instance.user_id) + + igs_in_payload = "preferred_ig_ids" in validated_data + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # Self-service: any APPROVED mentor (any tier) editing their Interest + # Groups takes effect immediately — no admin re-approval. IG mentoring is + # an orthogonal scope, so company/campus/global mentors can manage IGs too. + # (The profile PATCH endpoint already restricts to APPROVED mentors.) + if igs_in_payload and instance.status == UserMentor.Status.APPROVED: + from .dash_mentor_helper import reconcile_mentor_ig_links, reconcile_mentor_ig_grants + + actor_id = self.context.get("user_id", instance.user_id) + reconcile_mentor_ig_links(instance.user, instance.preferred_ig_ids, actor_id) + reconcile_mentor_ig_grants(instance, instance.preferred_ig_ids, actor_id) + + return instance + +class MentorListSerializer(serializers.ModelSerializer): + user_full_name = serializers.CharField(source='user.full_name', read_only=True) + user_email = serializers.CharField(source='user.email', read_only=True) + muid = serializers.CharField(source='user.muid', read_only=True) + + class Meta: + model = UserMentor + fields = [ + "id", + "user_id", + "user_full_name", + "user_email", + "muid", + "about", + "expertise", + "verification_note", + "verified_at", + "mentor_tier", + "status", + "created_at", + "updated_at" + ] + +class MentorDetailSerializer(serializers.ModelSerializer): + user_full_name = serializers.CharField(source='user.full_name', read_only=True) + user_email = serializers.CharField(source='user.email', read_only=True) + company = serializers.SerializerMethodField() + + class Meta: + model = UserMentor + # Explicit list — this serializer is also used by + # MentorPublicProfileAPI, so internal/audit-only columns + # (verification_note, created_by, updated_by) must never appear + # here regardless of what gets added to UserMentor in future. + fields = [ + "id", + "user", + "user_full_name", + "user_email", + "about", + "expertise", + "reason", + "hours", + "mentor_tier", + "status", + "preferred_ig_ids", + "org", + "verified_by", + "verified_at", + "company", + "created_at", + "updated_at", + ] + + def get_company(self, obj): + from .dash_mentor_helper import get_mentor_company + return get_mentor_company(obj) + +class MentorVerifySerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=[UserMentor.Status.APPROVED, UserMentor.Status.REJECTED]) + verification_note = serializers.CharField(required=False, allow_blank=True) + + def validate(self, data): + if data.get("status") == UserMentor.Status.REJECTED and not data.get("verification_note"): + raise serializers.ValidationError("Verification note is required when rejecting.") + return data + + def update(self, instance, validated_data): + user_id = self.context["user_id"] + status = validated_data.get("status") + + instance.status = status + instance.updated_by_id = user_id + instance.updated_at = DateTimeUtils.get_current_utc_time() + + if status == UserMentor.Status.APPROVED: + instance.verified_by_id = user_id + instance.verified_at = DateTimeUtils.get_current_utc_time() + + # Grant the tier being approved. Additive — never touches any + # other scope grant this mentor may already hold. IG_MENTOR is + # excluded here: it has no single scope_id of its own — its + # grants are always per-IG, created below via + # reconcile_mentor_ig_grants from preferred_ig_ids. Creating a + # scope_id=None IG_MENTOR grant here would be a meaningless + # "mentor for no particular IG" row alongside the real ones. + if instance.mentor_tier != UserMentor.MentorTier.IG_MENTOR: + scope_id = str(instance.org_id) if instance.org_id else None + grant, grant_created = MentorScopeGrant.objects.get_or_create( + mentor=instance, + scope_type=instance.mentor_tier, + scope_id=scope_id, + defaults={ + "is_active": True, + "granted_by_id": user_id, + "granted_at": DateTimeUtils.get_current_utc_time(), + }, + ) + if not grant_created and not grant.is_active: + grant.is_active = True + grant.revoked_by = None + grant.revoked_at = None + grant.save(update_fields=["is_active", "revoked_by", "revoked_at"]) + + # Assign global MENTOR role + mentor_role = Role.objects.filter(title=RoleType.MENTOR.value).first() + if mentor_role: + role_link, created = UserRoleLink.objects.get_or_create( + user=instance.user, + role=mentor_role, + defaults={ + "verified": True, + "created_by_id": user_id, + "created_at": DateTimeUtils.get_current_utc_time(), + }, + ) + if not created and not role_link.verified: + role_link.verified = True + role_link.save(update_fields=["verified"]) + + # Auto-assign UserIgLink from preferred IGs for ANY tier — IG + # mentoring is an orthogonal scope, so company/campus/global mentors + # can also mentor Interest Groups. Uses the shared reconciler + # (creates/reactivates chosen IGs, deactivates removed ones, only + # touches MENTOR-type links). + if instance.preferred_ig_ids: + from .dash_mentor_helper import reconcile_mentor_ig_links, reconcile_mentor_ig_grants + + reconcile_mentor_ig_links( + instance.user, instance.preferred_ig_ids, user_id + ) + reconcile_mentor_ig_grants( + instance, instance.preferred_ig_ids, user_id + ) + + # Auto-link COMPANY_MENTOR to the company's Organization + if instance.mentor_tier == UserMentor.MentorTier.COMPANY_MENTOR and instance.org: + from db.organization import UserOrganizationLink + org_link, created = UserOrganizationLink.objects.get_or_create( + user=instance.user, + org=instance.org, + defaults={ + "verified": True, + "created_by_id": user_id, + "created_at": DateTimeUtils.get_current_utc_time(), + }, + ) + if not created and not org_link.verified: + org_link.verified = True + org_link.save(update_fields=["verified"]) + + elif status == UserMentor.Status.REJECTED: + instance.verification_note = validated_data.get("verification_note") + + instance.save() + return instance + + +from db.mentor import MentorshipSession +from db.organization import Organization +from db.task import InterestGroup +from .session_recurrence_helper import generate_recurring_sessions + +class SessionCreateSerializer(serializers.ModelSerializer): + child_session_ids = serializers.SerializerMethodField() + + class Meta: + model = MentorshipSession + fields = [ + "id", + "entity_id", + "session_type", + "title", + "description", + "mode", + "starts_at", + "ends_at", + "meeting_link", + "venue", + "max_participants", + "is_recurring", + "recurrence_type", + "recurrence_interval", + "recurrence_end_date", + "child_session_ids" + ] + + def get_child_session_ids(self, obj): + if obj.is_recurring: + if hasattr(obj, '_child_session_ids'): + return obj._child_session_ids + return list( + MentorshipSession.objects.filter( + parent_session_id=obj.id, + is_deleted=False + ).values_list('id', flat=True) + ) + return [] + + def validate(self, data): + user_id = self.context.get("user_id") + if MentorshipSession.objects.filter( + title=data.get('title'), + starts_at=data.get('starts_at'), + entity_id=data.get('entity_id'), + created_by_id=user_id, + is_deleted=False + ).exists(): + raise serializers.ValidationError("A session with this exact title and start time already exists.") + + if data.get('starts_at') >= data.get('ends_at'): + raise serializers.ValidationError("Session start time must be before end time.") + + # ── Mode / venue constraints ──────────────────────────────────────── + mode = data.get('mode') + venue = data.get('venue') or "" + meeting_link = data.get('meeting_link') or "" + + if mode == MentorshipSession.Mode.ONLINE: + if venue.strip(): + raise serializers.ValidationError( + {"venue": "Venue must not be provided for an online session."} + ) + elif mode == MentorshipSession.Mode.OFFLINE: + if meeting_link.strip(): + raise serializers.ValidationError( + {"meeting_link": "Meeting link must not be provided for an offline session."} + ) + elif mode == MentorshipSession.Mode.HYBRID: + errors = {} + if not venue.strip(): + errors["venue"] = "Venue is required for a hybrid session." + if not meeting_link.strip(): + errors["meeting_link"] = "Meeting link is required for a hybrid session." + if errors: + raise serializers.ValidationError(errors) + + is_recurring = data.get('is_recurring', False) + if is_recurring: + errors = {} + if not data.get('recurrence_type'): + errors['recurrence_type'] = "recurrence_type is required when is_recurring is true." + if not data.get('recurrence_interval') or data.get('recurrence_interval') < 1: + errors['recurrence_interval'] = "recurrence_interval must be a positive integer when is_recurring is true." + if not data.get('recurrence_end_date'): + errors['recurrence_end_date'] = "recurrence_end_date is required when is_recurring is true." + elif data.get('starts_at') and data.get('recurrence_end_date') <= data.get('starts_at').date(): + errors['recurrence_end_date'] = "recurrence_end_date must be after the session starts_at date." + + if errors: + raise serializers.ValidationError(errors) + else: + data['recurrence_type'] = None + data['recurrence_interval'] = None + data['recurrence_end_date'] = None + + return data + + def create(self, validated_data): + user_id = self.context.get("user_id") + now = DateTimeUtils.get_current_utc_time() + + # Approved mentors are pre-vetted, so their sessions go live immediately + # (no per-session admin gate). Admins moderate reactively via cancel. + # This serializer is shared by the mentor and campus create paths; both + # are gated to approved mentors, so auto-approval is safe for both. + # Parent + recurring children are a multi-write → wrap atomically so a + # failure mid-series never leaves an orphan parent. + with transaction.atomic(): + session = MentorshipSession.objects.create( + status=MentorshipSession.Status.SCHEDULED, + approved_by_id=user_id, + approved_at=now, + created_by_id=user_id, + updated_by_id=user_id, + **validated_data + ) + + if session.is_recurring: + child_sessions = generate_recurring_sessions(session) + session._child_session_ids = [c.id for c in child_sessions] + else: + session._child_session_ids = [] + + return session + +class SessionUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = MentorshipSession + fields = [ + "title", + "description", + "mode", + "starts_at", + "ends_at", + "meeting_link", + "venue", + "max_participants" + ] + + def validate(self, data): + # Allow partial updates by fetching from instance if not in data + starts_at = data.get('starts_at', self.instance.starts_at) if self.instance else data.get('starts_at') + ends_at = data.get('ends_at', self.instance.ends_at) if self.instance else data.get('ends_at') + + if starts_at and ends_at and starts_at >= ends_at: + raise serializers.ValidationError("Session start time must be before end time.") + + # ── Mode / venue constraints (resolve from instance for partial PATCH) ── + mode = data.get('mode', self.instance.mode if self.instance else None) + venue = (data.get('venue') if 'venue' in data else (self.instance.venue if self.instance else None)) or "" + meeting_link = (data.get('meeting_link') if 'meeting_link' in data else (self.instance.meeting_link if self.instance else None)) or "" + + # On a mode change we auto-clear the field that no longer applies rather + # than rejecting stale data. Switching an online session to offline (or + # vice-versa) would otherwise fail because the previous meeting_link / + # venue is still stored on the row. + if mode == MentorshipSession.Mode.ONLINE: + data["venue"] = "" + elif mode == MentorshipSession.Mode.OFFLINE: + data["meeting_link"] = "" + elif mode == MentorshipSession.Mode.HYBRID: + errors = {} + if not venue.strip(): + errors["venue"] = "Venue is required for a hybrid session." + if not meeting_link.strip(): + errors["meeting_link"] = "Meeting link is required for a hybrid session." + if errors: + raise serializers.ValidationError(errors) + + return data + + def update(self, instance, validated_data): + user_id = self.context.get("user_id") + + # Editing no longer reverts a SCHEDULED session to PENDING_APPROVAL — + # approved mentors edit their own live sessions without re-approval. + instance.updated_by_id = user_id + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.save() + return instance + +class SessionListSerializer(serializers.ModelSerializer): + created_by_name = serializers.CharField(source='created_by.full_name', read_only=True) + entity_name = serializers.SerializerMethodField() + parent_session_id = serializers.CharField(read_only=True, allow_null=True) + + class Meta: + model = MentorshipSession + fields = [ + "id", + "entity_id", + "entity_name", + "session_type", + "title", + "description", + "mode", + "starts_at", + "ends_at", + "status", + "created_by_id", + "created_by_name", + "created_at", + "max_participants", + "meeting_link", + "venue", + "is_recurring", + "parent_session_id", + "recurrence_type", + "recurrence_interval", + "recurrence_end_date" + ] + + def get_entity_name(self, obj): + if obj.session_type == MentorshipSession.SessionType.IG_SESSION: + ig = InterestGroup.objects.filter(id=obj.entity_id).first() + return ig.name if ig else None + elif obj.session_type in ( + MentorshipSession.SessionType.CAMPUS_SESSION, + MentorshipSession.SessionType.COMPANY_SESSION, + ): + org = Organization.objects.filter(id=obj.entity_id).first() + return org.title if org else None + return None + +class SessionDetailSerializer(SessionListSerializer): + # All fields are already exposed by the list serializer; detail is kept as a + # distinct type for endpoint clarity / future divergence. + pass + +class AdminSessionVerifySerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=[ + MentorshipSession.Status.SCHEDULED, + MentorshipSession.Status.REJECTED, + MentorshipSession.Status.CANCELLED, + ]) + apply_to_series = serializers.BooleanField(default=False, required=False) + + def update(self, instance, validated_data): + user_id = self.context.get("user_id") + status = validated_data.get("status") + apply_to_series = validated_data.get("apply_to_series", False) + + now = DateTimeUtils.get_current_utc_time() + # Capture the pre-update status so a series bulk-update only touches + # siblings in the same source state (PENDING→approve/reject, + # SCHEDULED→cancel) — the transition is validated in the view. + source_status = instance.status + + with transaction.atomic(): + # 1. Update and save the targeted session instance (triggers cache signal once) + instance.status = status + instance.updated_by_id = user_id + + if status == MentorshipSession.Status.SCHEDULED: + instance.approved_by_id = user_id + instance.approved_at = now + + instance.save() + + # 2. Bulk update the rest of the series (same source state) if requested + if apply_to_series: + root_id = instance.parent_session_id or instance.id + update_kwargs = { + "status": status, + "updated_by_id": user_id, + } + if status == MentorshipSession.Status.SCHEDULED: + update_kwargs["approved_by_id"] = user_id + update_kwargs["approved_at"] = now + + MentorshipSession.objects.filter( + Q(id=root_id) | Q(parent_session_id=root_id), + status=source_status, + is_deleted=False + ).exclude(id=instance.id).update(**update_kwargs) + + # Make sure apply_to_series is available for view layer formatting + validated_data['apply_to_series'] = apply_to_series + return instance + +from db.mentor import MentorAvailabilitySlot + +class AvailabilitySlotSerializer(serializers.ModelSerializer): + ig_name = serializers.CharField(source='ig.name', read_only=True) + + class Meta: + model = MentorAvailabilitySlot + fields = [ + "id", + "mentor_user_id", + "ig_id", + "ig_name", + "weekday", + "start_time", + "end_time", + "timezone", + "is_active", + "valid_from", + "valid_to", + "created_at", + "updated_at" + ] + +class AvailabilitySlotCreateUpdateSerializer(serializers.ModelSerializer): + # Availability is mentor-level; an IG is optional. Allow omitting it or + # passing null so a slot can apply across all of the mentor's IGs. + ig = serializers.PrimaryKeyRelatedField( + queryset=InterestGroup.objects.all(), + required=False, + allow_null=True, + ) + + class Meta: + model = MentorAvailabilitySlot + fields = [ + "ig", + "weekday", + "start_time", + "end_time", + "timezone", + "is_active", + "valid_from", + "valid_to" + ] + + def validate(self, data): + start_time = data.get('start_time', self.instance.start_time if self.instance else None) + end_time = data.get('end_time', self.instance.end_time if self.instance else None) + weekday = data.get('weekday', self.instance.weekday if self.instance else None) + valid_from = data.get('valid_from', self.instance.valid_from if self.instance else None) + valid_to = data.get('valid_to', self.instance.valid_to if self.instance else None) + + if start_time and end_time and start_time >= end_time: + raise serializers.ValidationError("Start time must be before end time.") + + if weekday and (weekday < 1 or weekday > 7): + raise serializers.ValidationError("Weekday must be between 1 (Mon) and 7 (Sun).") + + if valid_from and valid_to and valid_from > valid_to: + raise serializers.ValidationError("Valid from date must be before or equal to valid to date.") + + return data + + def create(self, validated_data): + user_id = self.context.get("user_id") + + slot = MentorAvailabilitySlot.objects.create( + mentor_user_id=user_id, + created_by_id=user_id, + updated_by_id=user_id, + **validated_data + ) + return slot + + def update(self, instance, validated_data): + user_id = self.context.get("user_id") + instance.updated_by_id = user_id + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.save() + return instance + +from db.mentor import MentorshipSessionUserLink + +class ParticipantJoinSerializer(serializers.Serializer): + def create(self, validated_data): + user_id = self.context.get("user_id") + session_id = self.context.get("session_id") + + session = MentorshipSession.objects.filter(id=session_id).first() + if not session: + raise serializers.ValidationError("Session not found.") + + if session.status != MentorshipSession.Status.SCHEDULED: + raise serializers.ValidationError("Only scheduled sessions can be joined.") + + if session.max_participants: + current_count = MentorshipSessionUserLink.objects.filter(session_id=session_id).count() + if current_count >= session.max_participants: + raise serializers.ValidationError("Session has reached its maximum participant limit.") + + if MentorshipSessionUserLink.objects.filter(session_id=session_id, user_id=user_id).exists(): + raise serializers.ValidationError("You have already joined this session.") + + link = MentorshipSessionUserLink.objects.create( + session_id=session_id, + user_id=user_id, + participant_role=MentorshipSessionUserLink.ParticipantRole.MENTEE, + attendance_status=MentorshipSessionUserLink.AttendanceStatus.INVITED + ) + return link + +class MentorAddParticipantSerializer(serializers.Serializer): + muid = serializers.CharField(required=True) + + def create(self, validated_data): + mentor_id = self.context.get("user_id") + session_id = self.context.get("session_id") + muid = validated_data.get("muid") + + from db.user import User + user = User.objects.filter(muid=muid, suspended_at__isnull=True).first() + if not user: + raise serializers.ValidationError("User with this muid not found or is suspended.") + + session = MentorshipSession.objects.filter(id=session_id).first() + if not session: + raise serializers.ValidationError("Session not found.") + + # Verify the logged-in mentor created this session + if session.created_by_id != mentor_id: + raise serializers.ValidationError("You do not have permission to add participants to this session.") + + if session.status != MentorshipSession.Status.SCHEDULED: + raise serializers.ValidationError("Only scheduled sessions can accept participants.") + + if session.max_participants: + current_count = MentorshipSessionUserLink.objects.filter(session_id=session_id).count() + if current_count >= session.max_participants: + raise serializers.ValidationError("Session has reached its maximum participant limit.") + + if MentorshipSessionUserLink.objects.filter(session_id=session_id, user_id=user.id).exists(): + raise serializers.ValidationError("This user is already a participant of this session.") + + link = MentorshipSessionUserLink.objects.create( + session_id=session_id, + user_id=user.id, + participant_role=MentorshipSessionUserLink.ParticipantRole.MENTEE, + attendance_status=MentorshipSessionUserLink.AttendanceStatus.INVITED + ) + return link + + +class ParticipantListSerializer(serializers.ModelSerializer): + user_full_name = serializers.CharField(source='user.full_name', read_only=True) + mu_id = serializers.CharField(source='user.muid', read_only=True) + + # Session details — lets the participant-history view render a session + # (title/time/meeting link) without an extra detail fetch per row. + session_title = serializers.CharField(source="session.title", read_only=True) + session_starts_at = serializers.DateTimeField(source="session.starts_at", read_only=True) + session_ends_at = serializers.DateTimeField(source="session.ends_at", read_only=True) + session_mode = serializers.CharField(source="session.mode", read_only=True) + session_meeting_link = serializers.CharField(source="session.meeting_link", read_only=True) + session_venue = serializers.CharField(source="session.venue", read_only=True) + session_status = serializers.CharField(source="session.status", read_only=True) + session_entity_id = serializers.CharField(source="session.entity_id", read_only=True) + session_entity_name = serializers.SerializerMethodField() + + class Meta: + model = MentorshipSessionUserLink + fields = [ + "id", + "session_id", + "user_id", + "user_full_name", + "mu_id", + "participant_role", + "attendance_status", + "progress_note", + "feedback", + "contributed_minutes", + "created_at", + "session_title", + "session_starts_at", + "session_ends_at", + "session_mode", + "session_meeting_link", + "session_venue", + "session_status", + "session_entity_id", + "session_entity_name", + ] + + @staticmethod + def build_ig_map(links): + """ + Resolve the Interest Group name for a page of participant links in a + single query. List views should call this and pass the result as + ``context={"ig_map": ...}`` so ``get_session_entity_name`` does not fire + one InterestGroup query per row (entity_id is a CharField, not a FK, so + select_related cannot cover it). + """ + ig_ids = { + link.session.entity_id + for link in links + if getattr(link, "session", None) and link.session.entity_id + } + if not ig_ids: + return {} + return InterestGroup.objects.filter(id__in=ig_ids).in_bulk() + + def get_session_entity_name(self, obj): + session = getattr(obj, "session", None) + if not session or not session.entity_id: + return None + # All sessions are IG-scoped; resolve the Interest Group name. Prefer the + # batch-resolved map injected by list views (avoids an N+1); fall back to + # a single lookup for single-object responses that don't build a map. + ig_map = self.context.get("ig_map") + if ig_map is not None: + ig = ig_map.get(session.entity_id) + return ig.name if ig else None + ig = InterestGroup.objects.filter(id=session.entity_id).first() + return ig.name if ig else None + +class ParticipantUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = MentorshipSessionUserLink + fields = [ + "attendance_status", + "progress_note", + "contributed_minutes" + ] + + def validate(self, data): + contributed_minutes = data.get('contributed_minutes') + if contributed_minutes is not None and contributed_minutes <= 0: + raise serializers.ValidationError("Contributed minutes must be greater than zero.") + return data + +class ParticipantFeedbackSerializer(serializers.ModelSerializer): + class Meta: + model = MentorshipSessionUserLink + fields = ["feedback"] + + def validate(self, data): + if not data.get("feedback"): + raise serializers.ValidationError("Feedback cannot be empty.") + + if self.instance.attendance_status != MentorshipSessionUserLink.AttendanceStatus.ATTENDED: + raise serializers.ValidationError("You can only leave feedback for sessions you have attended.") + + return data + +class MentorActivitySerializer(serializers.Serializer): + id = serializers.CharField() + activity_type = serializers.CharField() + title = serializers.CharField() + description = serializers.CharField(allow_null=True, required=False) + date = serializers.DateTimeField() + status = serializers.CharField(allow_null=True, required=False) + + +class AdminAssignMentorSerializer(serializers.Serializer): + """ + Validates and performs bulk admin assignment of users as mentors. + + The caller supplies a list of user_muids plus tier-specific fields. + All DB side-effects for every user in the list are executed inside a + single atomic transaction — if validation passes for the whole batch. + """ + + from db.user import User as _User + from db.organization import Organization as _Organization + from utils.types import OrganizationType as _OrganizationType + + user_muids = serializers.ListField( + child=serializers.CharField(), + min_length=1, + help_text="List of user muids to assign as mentor." + ) + mentor_tier = serializers.ChoiceField(choices=UserMentor.MentorTier.choices) + org_id = serializers.CharField(required=False, allow_null=True, default=None) + ig_ids = serializers.ListField( + child=serializers.CharField(), + required=False, + allow_empty=True, + default=list, + help_text="List of InterestGroup UUIDs — required for IG_MENTOR tier." + ) + about = serializers.CharField(required=False, allow_blank=True, default=None) + expertise = serializers.CharField(required=False, allow_blank=True, default=None) + hours = serializers.IntegerField(required=False, min_value=0, default=0) + + def validate(self, data): + from db.user import User + from db.organization import Organization + from utils.types import OrganizationType + + tier = data["mentor_tier"] + errors = {} + + # ── Resolve all user_muids ────────────────────────────────────────── + resolved_users = {} + invalid_muids = [] + for muid in data["user_muids"]: + user = User.objects.filter(muid=muid, suspended_at__isnull=True).first() + if user is None: + invalid_muids.append(muid) + else: + resolved_users[muid] = user + + if invalid_muids: + errors["user_muids"] = ( + f"The following muids are invalid or belong to suspended users: " + f"{', '.join(invalid_muids)}" + ) + + # ── Tier-specific validation ──────────────────────────────────────── + if tier in (UserMentor.MentorTier.CAMPUS_MENTOR, UserMentor.MentorTier.COMPANY_MENTOR): + org_id = data.get("org_id") + if not org_id: + errors["org_id"] = f"org_id is required for {tier}." + else: + expected_org_type = ( + OrganizationType.COLLEGE.value + if tier == UserMentor.MentorTier.CAMPUS_MENTOR + else OrganizationType.COMPANY.value + ) + org = Organization.objects.filter(id=org_id, org_type=expected_org_type).first() + if org is None: + errors["org_id"] = ( + f"org_id must reference a valid {expected_org_type} organisation." + ) + else: + data["_org"] = org + + if tier == UserMentor.MentorTier.IG_MENTOR: + ig_ids = data.get("ig_ids") or [] + if not ig_ids: + errors["ig_ids"] = "ig_ids (non-empty list) is required for IG_MENTOR." + else: + invalid_igs = [ + ig_id for ig_id in ig_ids + if not InterestGroup.objects.filter(id=ig_id).exists() + ] + if invalid_igs: + errors["ig_ids"] = ( + f"The following IG IDs are invalid: {', '.join(invalid_igs)}" + ) + + if errors: + raise serializers.ValidationError(errors) + + data["_resolved_users"] = resolved_users + return data + + def create(self, validated_data): + """ + Atomically create/update UserMentor records + all linked DB objects + for every user in the bulk list. + Returns the list of muids that were successfully assigned. + """ + admin_id = self.context["user_id"] + tier = validated_data["mentor_tier"] + org = validated_data.get("_org") + ig_ids = validated_data.get("ig_ids") or [] + resolved_users = validated_data["_resolved_users"] + now = DateTimeUtils.get_current_utc_time() + + # Fetch the platform-wide Mentor role once (shared across all users) + mentor_role = Role.objects.filter(title=RoleType.MENTOR.value).first() + + with transaction.atomic(): + for muid, user in resolved_users.items(): + # ── 1. Create / update UserMentor record ─────────────────── + mentor_record, _ = UserMentor.objects.get_or_create( + user=user, + mentor_tier=tier, + org=org, + defaults={ + "status": UserMentor.Status.APPROVED, + "about": validated_data.get("about"), + "expertise": validated_data.get("expertise"), + "hours": validated_data.get("hours", 0), + "preferred_ig_ids": ig_ids if ig_ids else None, + "verified_by_id": admin_id, + "verified_at": now, + "updated_by_id": admin_id, + "updated_at": now, + "created_by_id": admin_id, + "created_at": now, + }, + ) + # Idempotent: if it already exists, force-approve it + if mentor_record.status != UserMentor.Status.APPROVED: + mentor_record.status = UserMentor.Status.APPROVED + mentor_record.verified_by_id = admin_id + mentor_record.verified_at = now + mentor_record.updated_by_id = admin_id + mentor_record.updated_at = now + mentor_record.save(update_fields=[ + "status", "verified_by_id", "verified_at", + "updated_by_id", "updated_at", + ]) + + # ── 1b. Grant the tier being assigned (additive). IG_MENTOR + # is excluded — it has no single scope_id of its own; its + # grants are per-IG, created in the ig_ids loop below. + if tier != UserMentor.MentorTier.IG_MENTOR: + scope_id = str(org.id) if org else None + grant, grant_created = MentorScopeGrant.objects.get_or_create( + mentor=mentor_record, + scope_type=tier, + scope_id=scope_id, + defaults={ + "is_active": True, + "granted_by_id": admin_id, + "granted_at": now, + }, + ) + if not grant_created and not grant.is_active: + grant.is_active = True + grant.revoked_by = None + grant.revoked_at = None + grant.save(update_fields=["is_active", "revoked_by", "revoked_at"]) + + # ── 2. Assign global Mentor role ──────────────────────────── + if mentor_role: + role_link, created = UserRoleLink.objects.get_or_create( + user=user, + role=mentor_role, + defaults={ + "verified": True, + "created_by_id": admin_id, + "created_at": now, + }, + ) + if not created and not role_link.verified: + role_link.verified = True + role_link.save(update_fields=["verified"]) + + # ── 3. Side-effects (IG links + org link can coexist) ─────── + # Every mentor tier can mentor Interest Groups, so create MENTOR + # UserIgLink rows for ANY tier whenever ig_ids are supplied — + # not just IG_MENTOR. Without this, a company/campus mentor's + # preferred_ig_ids snapshot would be saved while the authoritative + # UserIgLink rows (used by session-create + the IG dropdown) stay + # empty, leaving them unable to create sessions for IGs they + # appear to mentor. + if ig_ids: + for ig_id in ig_ids: + ig = InterestGroup.objects.filter(id=ig_id).first() + if ig: + ig_link, created = UserIgLink.objects.get_or_create( + user=user, + ig=ig, + defaults={ + "assignment_type": UserIgLink.AssignmentType.MENTOR, + "is_active": True, + "assigned_by_id": admin_id, + "created_by_id": admin_id, + "created_at": now, + }, + ) + if not created: + ig_link.assignment_type = UserIgLink.AssignmentType.MENTOR + ig_link.is_active = True + ig_link.assigned_by_id = admin_id + ig_link.save(update_fields=["assignment_type", "is_active", "assigned_by_id"]) + + ig_grant, ig_grant_created = MentorScopeGrant.objects.get_or_create( + mentor=mentor_record, + scope_type=MentorScopeGrant.ScopeType.IG_MENTOR, + scope_id=str(ig_id), + defaults={ + "is_active": True, + "granted_by_id": admin_id, + "granted_at": now, + }, + ) + if not ig_grant_created and not ig_grant.is_active: + ig_grant.is_active = True + ig_grant.revoked_by = None + ig_grant.revoked_at = None + ig_grant.save(update_fields=["is_active", "revoked_by", "revoked_at"]) + + # Company/campus mentors also get their verified org link. + if tier in (UserMentor.MentorTier.CAMPUS_MENTOR, UserMentor.MentorTier.COMPANY_MENTOR) and org: + from db.organization import UserOrganizationLink + org_link, created = UserOrganizationLink.objects.get_or_create( + user=user, + org=org, + defaults={ + "verified": True, + "created_by_id": admin_id, + "created_at": now, + }, + ) + if not created and not org_link.verified: + org_link.verified = True + org_link.save(update_fields=["verified"]) + + return list(resolved_users.keys()) + + +# ───────────────────────────────────────────────────────────────────────────── +# Student session-request serializers +# ───────────────────────────────────────────────────────────────────────────── + +class StudentSessionRequestSerializer(serializers.ModelSerializer): + """ + Used by students to create a session request. + + All sessions are Interest-Group scoped, so only ``ig_session`` requests are + accepted; company/campus session types are rejected. + + Validates: + - The session_type is ig_session and the student is a member of the target + Interest Group (entity_id). + - Time constraints (starts_at < ends_at, starts_at in future). + - Mode / venue / meeting-link consistency (same rules as SessionCreateSerializer). + - No duplicate pending request (same student + entity + title + starts_at). + """ + + class Meta: + model = MentorshipSession + fields = [ + "id", + "session_type", + "entity_id", + "title", + "description", + "mode", + "starts_at", + "ends_at", + "meeting_link", + "venue", + "max_participants", + ] + + def validate(self, data): + from db.task import UserIgLink + from django.utils import timezone + + user_id = self.context["user_id"] + session_type = data.get("session_type") + entity_id = data.get("entity_id") + starts_at = data.get("starts_at") + ends_at = data.get("ends_at") + + # ── Time guards ────────────────────────────────────────────────────── + if starts_at and starts_at <= timezone.now(): + raise serializers.ValidationError( + {"starts_at": "Session start time must be in the future."} + ) + + if starts_at and ends_at and starts_at >= ends_at: + raise serializers.ValidationError( + "Session start time must be before end time." + ) + + # ── Entity-membership validation ───────────────────────────────────── + # All sessions are Interest-Group scoped. Company- and campus-scoped + # sessions are no longer supported, so any non-IG session_type is + # rejected rather than persisted. + if session_type != MentorshipSession.SessionType.IG_SESSION: + raise serializers.ValidationError( + { + "session_type": ( + "Only Interest Group sessions can be requested. " + "Company- and campus-scoped sessions are not supported." + ) + } + ) + + if not UserIgLink.objects.filter( + user_id=user_id, + ig_id=entity_id, + is_active=True, + ).exists(): + raise serializers.ValidationError( + {"entity_id": "You are not a member of this Interest Group."} + ) + + # ── Mode / venue / meeting-link constraints ────────────────────────── + mode = data.get("mode") + venue = (data.get("venue") or "").strip() + meeting_link = (data.get("meeting_link") or "").strip() + + if mode == MentorshipSession.Mode.ONLINE and venue: + raise serializers.ValidationError( + {"venue": "Venue must not be provided for an online session."} + ) + elif mode == MentorshipSession.Mode.OFFLINE and meeting_link: + raise serializers.ValidationError( + {"meeting_link": "Meeting link must not be provided for an offline session."} + ) + elif mode == MentorshipSession.Mode.HYBRID: + errors = {} + if not venue: + errors["venue"] = "Venue is required for a hybrid session." + if not meeting_link: + errors["meeting_link"] = "Meeting link is required for a hybrid session." + if errors: + raise serializers.ValidationError(errors) + + # ── Duplicate request guard ────────────────────────────────────────── + if MentorshipSession.objects.filter( + requested_by_id=user_id, + entity_id=entity_id, + title=data.get("title"), + starts_at=starts_at, + status=MentorshipSession.Status.REQUESTED, + is_deleted=False, + ).exists(): + raise serializers.ValidationError( + "You already have a pending request for a session with the same " + "title and start time for this entity." + ) + + return data + + def create(self, validated_data): + user_id = self.context["user_id"] + + session = MentorshipSession.objects.create( + status=MentorshipSession.Status.REQUESTED, + requested_by_id=user_id, + created_by_id=user_id, + updated_by_id=user_id, + **validated_data, + ) + + # Register the requesting student as an invited MENTEE immediately + from db.mentor import MentorshipSessionUserLink + MentorshipSessionUserLink.objects.create( + session=session, + user_id=user_id, + participant_role=MentorshipSessionUserLink.ParticipantRole.MENTEE, + attendance_status=MentorshipSessionUserLink.AttendanceStatus.INVITED, + ) + + return session + + +class StudentSessionRequestListSerializer(serializers.ModelSerializer): + """ + Read-only serializer used by mentors to see incoming student session requests. + Includes the requesting student's name for quick identification. + """ + requested_by_name = serializers.CharField(source="requested_by.full_name", read_only=True) + requested_by_muid = serializers.CharField(source="requested_by.muid", read_only=True) + entity_name = serializers.SerializerMethodField() + + class Meta: + model = MentorshipSession + fields = [ + "id", + "session_type", + "entity_id", + "entity_name", + "title", + "description", + "mode", + "starts_at", + "ends_at", + "meeting_link", + "venue", + "max_participants", + "status", + "requested_by_id", + "requested_by_name", + "requested_by_muid", + "created_at", + ] + + def get_entity_name(self, obj): + if obj.session_type == MentorshipSession.SessionType.IG_SESSION: + ig = InterestGroup.objects.filter(id=obj.entity_id).first() + return ig.name if ig else None + elif obj.session_type in ( + MentorshipSession.SessionType.CAMPUS_SESSION, + MentorshipSession.SessionType.COMPANY_SESSION, + ): + org = Organization.objects.filter(id=obj.entity_id).first() + return org.title if org else None + return None + + +class MentorStudentRequestVerifySerializer(serializers.Serializer): + """ + Allows a mentor to approve or reject a student's session request. + + On APPROVE: + - Optional overrides (starts_at, ends_at, mode, meeting_link, venue) are + applied so the mentor can adjust the proposed time / logistics. + - The session transitions from REQUESTED → PENDING_APPROVAL. + - created_by is updated to the approving mentor so the session appears in + their dashboard and they can edit/cancel it using existing APIs. + - requested_by is left untouched — permanent audit trail. + + On REJECT: + - The session transitions from REQUESTED → REJECTED. + - requested_by remains set; the student can view their rejected requests. + """ + + status = serializers.ChoiceField(choices=["APPROVED", "REJECTED"]) + # Optional mentor-provided overrides — applied only when status == APPROVED + starts_at = serializers.DateTimeField(required=False, allow_null=True) + ends_at = serializers.DateTimeField(required=False, allow_null=True) + mode = serializers.ChoiceField( + choices=MentorshipSession.Mode.choices, required=False, allow_null=True + ) + meeting_link = serializers.CharField(required=False, allow_blank=True, allow_null=True) + venue = serializers.CharField(required=False, allow_blank=True, allow_null=True) + + def validate(self, data): + from django.utils import timezone + + action = data.get("status") + starts_at = data.get("starts_at") + ends_at = data.get("ends_at") + + if action == "APPROVED": + # Resolve effective times (override or fall back to existing session values) + effective_starts = starts_at or self.instance.starts_at + effective_ends = ends_at or self.instance.ends_at + + if effective_starts <= timezone.now(): + raise serializers.ValidationError( + {"starts_at": "Session start time must be in the future."} + ) + + if effective_starts >= effective_ends: + raise serializers.ValidationError( + "Session start time must be before end time." + ) + + # Resolve effective mode/venue/link for consistency check + effective_mode = data.get("mode") or self.instance.mode + effective_venue = (data.get("venue") or self.instance.venue or "").strip() + effective_link = (data.get("meeting_link") or self.instance.meeting_link or "").strip() + + if effective_mode == MentorshipSession.Mode.ONLINE and effective_venue: + raise serializers.ValidationError( + {"venue": "Venue must not be provided for an online session."} + ) + elif effective_mode == MentorshipSession.Mode.OFFLINE and effective_link: + raise serializers.ValidationError( + {"meeting_link": "Meeting link must not be provided for an offline session."} + ) + elif effective_mode == MentorshipSession.Mode.HYBRID: + errors = {} + if not effective_venue: + errors["venue"] = "Venue is required for a hybrid session." + if not effective_link: + errors["meeting_link"] = "Meeting link is required for a hybrid session." + if errors: + raise serializers.ValidationError(errors) + + return data + + def update(self, instance, validated_data): + mentor_id = self.context["user_id"] + action = validated_data["status"] + + if action == "APPROVED": + # Apply optional mentor overrides + override_fields = ["starts_at", "ends_at", "mode", "meeting_link", "venue"] + for field in override_fields: + value = validated_data.get(field) + if value is not None: + setattr(instance, field, value) + + # Mentor approval is the trust gate — the session goes live directly + # (no separate admin approval). + instance.status = MentorshipSession.Status.SCHEDULED + instance.approved_by_id = mentor_id + instance.approved_at = DateTimeUtils.get_current_utc_time() + instance.created_by_id = mentor_id # mentor takes ownership + instance.updated_by_id = mentor_id + instance.save() + + else: # REJECTED + instance.status = MentorshipSession.Status.REJECTED + instance.updated_by_id = mentor_id + instance.save(update_fields=["status", "updated_by_id"]) + + return instance diff --git a/api/dashboard/mentor/session_recurrence_helper.py b/api/dashboard/mentor/session_recurrence_helper.py new file mode 100644 index 000000000..bc6ee474e --- /dev/null +++ b/api/dashboard/mentor/session_recurrence_helper.py @@ -0,0 +1,85 @@ +from datetime import timedelta +from dateutil.relativedelta import relativedelta +from django.utils import timezone +from db.mentor import MentorshipSession +import uuid + +MAX_RECURRENCE_COUNT = 50 + +def generate_recurring_sessions(parent_session: MentorshipSession): + """ + Generate future MentorshipSession instances based on the recurrence rules + of the given parent_session. + + Returns a list of created child sessions. + """ + if not parent_session.is_recurring: + return [] + + if not parent_session.recurrence_type or not parent_session.recurrence_interval or not parent_session.recurrence_end_date: + return [] + + child_sessions = [] + + # Calculate duration of the session + duration = parent_session.ends_at - parent_session.starts_at + + current_start = parent_session.starts_at + end_date = parent_session.recurrence_end_date + interval = parent_session.recurrence_interval + r_type = parent_session.recurrence_type + + # Start loop + count = 0 + while count < MAX_RECURRENCE_COUNT: + if r_type == MentorshipSession.RecurrenceType.DAILY: + current_start += timedelta(days=interval) + elif r_type == MentorshipSession.RecurrenceType.WEEKLY: + current_start += timedelta(weeks=interval) + elif r_type == MentorshipSession.RecurrenceType.MONTHLY: + current_start += relativedelta(months=interval) + else: + break + + # Check if we exceeded the end date + # We compare the date part only as recurrence_end_date is a DateField + if current_start.date() > end_date: + break + + current_end = current_start + duration + + # Create child instance + child_session = MentorshipSession( + id=str(uuid.uuid4()), + session_type=parent_session.session_type, + entity_id=parent_session.entity_id, + title=parent_session.title, + description=parent_session.description, + mode=parent_session.mode, + starts_at=current_start, + ends_at=current_end, + meeting_link=parent_session.meeting_link, + venue=parent_session.venue, + status=parent_session.status, + approved_by_id=parent_session.approved_by_id, + approved_at=parent_session.approved_at, + max_participants=parent_session.max_participants, + is_deleted=parent_session.is_deleted, + + is_recurring=True, + recurrence_type=parent_session.recurrence_type, + recurrence_interval=parent_session.recurrence_interval, + recurrence_end_date=parent_session.recurrence_end_date, + parent_session_id=parent_session.id, + + created_by_id=parent_session.created_by_id, + updated_by_id=parent_session.updated_by_id, + # We don't auto-approve children if parent is pending, but since parent just got created, status is PENDING_APPROVAL. + ) + child_sessions.append(child_session) + count += 1 + + if child_sessions: + MentorshipSession.objects.bulk_create(child_sessions) + + return child_sessions diff --git a/api/dashboard/mentor/session_views.py b/api/dashboard/mentor/session_views.py new file mode 100644 index 000000000..03745b5e9 --- /dev/null +++ b/api/dashboard/mentor/session_views.py @@ -0,0 +1,319 @@ +from rest_framework.views import APIView +from django.db.models import Q +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.mentor import MentorshipSession +from db.task import UserIgLink +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from . import serializers + +class MentorSessionCreateAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session'], + description=( + "Create a new mentorship session. " + "For IG mentors, provide an 'ig' field. " + "For company mentors, entity_id and session_type are auto-resolved from the mentor's company org. " + "To create a recurring session, set is_recurring=true, recurrence_type ('DAILY', 'WEEKLY', 'MONTHLY'), " + "recurrence_interval, and recurrence_end_date. This returns the parent session while auto-spawning children." + ), + request=serializers.SessionCreateSerializer, + responses={200: serializers.SessionCreateSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + data = request.data.copy() + + # ── IG-scoped sessions only ────────────────────────────────────────── + # Every session is for an Interest Group the mentor mentors (an active + # MENTOR UserIgLink). There are no company- or campus-scoped sessions. + ig_id = (request.data.get("ig") or "").strip() + + if not ig_id: + return CustomResponse( + general_message="Select an Interest Group to create a session." + ).get_failure_response(status_code=400) + + if not UserIgLink.objects.filter( + user_id=user_id, + ig_id=ig_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).exists(): + return CustomResponse( + general_message="You are not assigned as a mentor for this Interest Group." + ).get_failure_response(status_code=403) + + data["entity_id"] = ig_id + data["session_type"] = MentorshipSession.SessionType.IG_SESSION + + # Pop 'ig' to prevent DRF unknown field validation errors + data.pop("ig", None) + + serializer = serializers.SessionCreateSerializer( + data=data, context={"user_id": user_id} + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Session created successfully.", + response=serializer.data + ).get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + + +class MentorSessionListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session'], + description="List all sessions created by the logged-in mentor, or get details of a specific session.", + parameters=[ + OpenApiParameter("status", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + ], + responses={200: serializers.SessionListSerializer(many=True)}, + ) + @role_required([RoleType.MENTOR.value]) + def get(self, request, session_id=None): + user_id = JWTUtils.fetch_user_id(request) + + if session_id: + session = MentorshipSession.objects.filter(id=session_id, created_by_id=user_id, is_deleted=False).first() + if not session: + return CustomResponse(general_message="Session not found.").get_failure_response(status_code=404) + serializer = serializers.SessionDetailSerializer(session) + return CustomResponse(response=serializer.data).get_success_response() + + sessions = MentorshipSession.objects.filter(created_by_id=user_id, is_deleted=False) + + status = request.query_params.get("status") + if status: + sessions = sessions.filter(status=status) + + paginated_queryset = CommonUtils.get_paginated_queryset( + sessions, request, + search_fields=["title", "description"], + sort_fields={"created_at": "created_at", "starts_at": "starts_at"} + ) + + serializer = serializers.SessionListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + +class MentorSessionUpdateAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session'], + description="Update or cancel a mentorship session.", + request=serializers.SessionUpdateSerializer, + responses={200: serializers.SessionUpdateSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def patch(self, request, session_id): + user_id = JWTUtils.fetch_user_id(request) + session = MentorshipSession.objects.filter(id=session_id, created_by_id=user_id, is_deleted=False).first() + + if not session: + return CustomResponse( + general_message="Session not found." + ).get_failure_response(status_code=404) + + if session.status in [MentorshipSession.Status.COMPLETED, MentorshipSession.Status.CANCELLED, MentorshipSession.Status.REJECTED]: + return CustomResponse( + general_message=f"Cannot edit a session that is {session.status.lower()}." + ).get_failure_response() + + serializer = serializers.SessionUpdateSerializer( + session, data=request.data, partial=True, context={"user_id": user_id} + ) + + if serializer.is_valid(): + serializer.save() + return CustomResponse( + general_message="Session updated successfully. Status reset to pending if previously scheduled.", + response=serializer.data + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + @extend_schema( + tags=['Dashboard - Mentor Session'], + description="Delete a mentorship session.", + ) + @role_required([RoleType.MENTOR.value]) + def delete(self, request, session_id): + user_id = JWTUtils.fetch_user_id(request) + session = MentorshipSession.objects.filter(id=session_id, created_by_id=user_id, is_deleted=False).first() + + if not session: + return CustomResponse( + general_message="Session not found." + ).get_failure_response(status_code=404) + + session.is_deleted = True + session.save() + return CustomResponse( + general_message="Session deleted successfully." + ).get_success_response() + +class AdminSessionListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session'], + description="Admin view to list all mentorship sessions.", + parameters=[ + OpenApiParameter("status", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + OpenApiParameter("ig_id", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False), + ], + responses={200: serializers.SessionListSerializer(many=True)}, + ) + @role_required([RoleType.ADMIN.value]) + def get(self, request): + sessions = MentorshipSession.objects.filter(is_deleted=False) + + status = request.query_params.get("status") + ig_id = request.query_params.get("ig_id") + + if status: + sessions = sessions.filter(status=status) + if ig_id: + sessions = sessions.filter(entity_id=ig_id, session_type=MentorshipSession.SessionType.IG_SESSION) + + paginated_queryset = CommonUtils.get_paginated_queryset( + sessions, request, + search_fields=["title", "created_by__full_name"], + sort_fields={"starts_at": "starts_at", "created_at": "created_at", "status": "status"} + ) + + serializer = serializers.SessionListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + +class AdminSessionVerifyAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Mentor Session'], + description="Verify or reject a mentorship session.", + request=serializers.AdminSessionVerifySerializer, + ) + @role_required([RoleType.ADMIN.value]) + def patch(self, request, session_id): + user_id = JWTUtils.fetch_user_id(request) + session = MentorshipSession.objects.filter(id=session_id, is_deleted=False).first() + + if not session: + return CustomResponse( + general_message="Session not found." + ).get_failure_response(status_code=404) + + # Allowed admin transitions: + # PENDING_APPROVAL → SCHEDULED / REJECTED (legacy pre-moderation) + # SCHEDULED → CANCELLED (reactive unpublish) + target = request.data.get("status") + allowed_transition = ( + ( + session.status == MentorshipSession.Status.PENDING_APPROVAL + and target in [ + MentorshipSession.Status.SCHEDULED, + MentorshipSession.Status.REJECTED, + ] + ) + or ( + session.status == MentorshipSession.Status.SCHEDULED + and target == MentorshipSession.Status.CANCELLED + ) + ) + if not allowed_transition: + return CustomResponse( + general_message="Invalid status transition for this session." + ).get_failure_response(status_code=400) + + serializer = serializers.AdminSessionVerifySerializer( + session, data=request.data, context={"user_id": user_id} + ) + + if serializer.is_valid(): + instance = serializer.save() + status = serializer.validated_data.get('status') + apply_to_series = serializer.validated_data.get('apply_to_series', False) + + message = f"Session status updated to {status} successfully." + if apply_to_series and instance.is_recurring: + message = f"Session and its pending series chain updated to {status} successfully." + + return CustomResponse( + general_message=message + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + +class AvailableSessionListAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Learner Session'], + description=( + "List all scheduled sessions available to the user — " + "includes IG sessions for their groups and company sessions for linked company orgs." + ), + responses={200: serializers.SessionListSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + from django.db.models import Q + from db.task import UserIgLink + from db.organization import UserOrganizationLink + + # IGs the user belongs to + user_ig_ids = UserIgLink.objects.filter( + user_id=user_id + ).values_list('ig_id', flat=True) + + # Company orgs the user is linked to + company_org_ids = UserOrganizationLink.objects.filter( + user_id=user_id, + org__org_type='Company', + ).values_list('org_id', flat=True) + + sessions = MentorshipSession.objects.filter( + Q( + entity_id__in=user_ig_ids, + session_type=MentorshipSession.SessionType.IG_SESSION, + ) | Q( + entity_id__in=company_org_ids, + session_type=MentorshipSession.SessionType.COMPANY_SESSION, + ), + status=MentorshipSession.Status.SCHEDULED, + is_deleted=False, + ) + + paginated_queryset = CommonUtils.get_paginated_queryset( + sessions, request, + search_fields=["title", "description"], + sort_fields={"created_at": "created_at", "starts_at": "starts_at"} + ) + + serializer = serializers.SessionListSerializer(paginated_queryset.get("queryset"), many=True) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() diff --git a/api/dashboard/mentor/student_requests_views.py b/api/dashboard/mentor/student_requests_views.py new file mode 100644 index 000000000..6650f57c4 --- /dev/null +++ b/api/dashboard/mentor/student_requests_views.py @@ -0,0 +1,305 @@ +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes + +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils +from db.mentor import MentorshipSession +from . import serializers + + +class StudentSessionRequestAPI(APIView): + """ + POST /session/student/request/ + + Allows any authenticated user (students, learners, etc.) to request a + mentorship session from the mentor(s) assigned to their entity. + + The request is created with status=REQUESTED and the student is + automatically added as an invited MENTEE. The `requested_by` field + permanently records the originating student even after the mentor + adopts the session. + + Validation enforced: + - Student must be a verified member of the target entity. + - `starts_at` must be in the future and before `ends_at`. + - Mode / venue / meeting-link rules (same as session create). + - No duplicate REQUESTED session (same student + entity + title + starts_at). + """ + + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Student Session Request"], + description=( + "Request a mentorship session from the mentor(s) of a campus, company, or IG. " + "The student must be a verified member of the target entity. " + "The session starts in REQUESTED status and waits for a mentor to approve or reject it." + ), + request=serializers.StudentSessionRequestSerializer, + responses={201: serializers.StudentSessionRequestSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + + serializer = serializers.StudentSessionRequestSerializer( + data=request.data, + context={"user_id": user_id}, + ) + if serializer.is_valid(): + session = serializer.save() + return CustomResponse( + general_message="Session request submitted successfully. " + "A mentor will review your request shortly.", + response=serializers.StudentSessionRequestSerializer(session).data, + ).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + +class StudentSessionRequestListAPI(APIView): + """ + GET /session/student/my-requests/ + + Returns the logged-in student's own session requests, optionally filtered + by status (REQUESTED / APPROVED / REJECTED). + """ + + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Student Session Request"], + description="List all session requests made by the logged-in student.", + parameters=[ + OpenApiParameter( + "status", OpenApiTypes.STR, OpenApiParameter.QUERY, required=False, + description="Filter by status: REQUESTED, PENDING_APPROVAL, REJECTED, etc." + ), + ], + responses={200: serializers.StudentSessionRequestListSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + sessions = MentorshipSession.objects.filter( + requested_by_id=user_id, + is_deleted=False, + ) + + status_filter = request.query_params.get("status") + if status_filter: + sessions = sessions.filter(status=status_filter) + + paginated_queryset = CommonUtils.get_paginated_queryset( + sessions, request, + search_fields=["title", "description"], + sort_fields={"created_at": "created_at", "starts_at": "starts_at"}, + ) + + serializer = serializers.StudentSessionRequestListSerializer( + paginated_queryset.get("queryset"), many=True + ) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class MentorStudentRequestListAPI(APIView): + """ + GET /session/student-requests/ + + Returns all REQUESTED sessions that fall within the logged-in mentor's + scope. All sessions are Interest-Group scoped, so a mentor sees requests + for the IGs they actively mentor — regardless of mentor tier. + + Scoping rules: + - Global MENTOR → All REQUESTED sessions (admin-level visibility). + - Any other tier (IG / COMPANY / CAMPUS mentor) → IG_SESSION requests + whose entity_id is one of the mentor's active MENTOR Interest Groups. + (A company/campus mentor who also mentors IGs sees those IG requests.) + """ + + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Mentor Session Request"], + description=( + "List all pending student session requests that are within the logged-in " + "mentor's scope (their assigned IGs, campus, or company)." + ), + responses={200: serializers.StudentSessionRequestListSerializer(many=True)}, + ) + @role_required([RoleType.MENTOR.value]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + from db.task import UserIgLink + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import get_mentor_scopes + + # Active scopes for this user (grant-driven, falls back to + # mentor_tier for accounts that predate grants). + scopes = get_mentor_scopes(user_id) + + if not scopes: + # Has the MENTOR role but no approved record → no visibility. + sessions = MentorshipSession.objects.none() + elif (MentorScopeGrant.ScopeType.MENTOR, None) in scopes: + # Global mentor: admin-level visibility into all pending requests. + sessions = MentorshipSession.objects.filter( + status=MentorshipSession.Status.REQUESTED, + is_deleted=False, + ) + else: + # Any other tier mentors Interest Groups via active MENTOR links. + # All sessions are IG-scoped, so they see IG_SESSION requests for + # the IGs they actively mentor — independent of tier. + ig_ids = list( + UserIgLink.objects.filter( + user_id=user_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).values_list("ig_id", flat=True) + ) + if not ig_ids: + sessions = MentorshipSession.objects.none() + else: + sessions = MentorshipSession.objects.filter( + session_type=MentorshipSession.SessionType.IG_SESSION, + entity_id__in=ig_ids, + status=MentorshipSession.Status.REQUESTED, + is_deleted=False, + ) + + paginated_queryset = CommonUtils.get_paginated_queryset( + sessions, request, + search_fields=["title", "requested_by__full_name"], + sort_fields={"created_at": "created_at", "starts_at": "starts_at"}, + ) + + serializer = serializers.StudentSessionRequestListSerializer( + paginated_queryset.get("queryset"), many=True + ) + return CustomResponse( + response={ + "data": serializer.data, + "pagination": paginated_queryset.get("pagination"), + } + ).get_success_response() + + +class MentorStudentRequestVerifyAPI(APIView): + """ + PATCH /session/student-requests//verify/ + + Allows a mentor to approve or reject a student's session request. + + The mentor must be eligible to act on the session (their scope must + include the session's entity_id + session_type). + + On APPROVE: + - The session moves from REQUESTED → PENDING_APPROVAL. + - `created_by` is updated to this mentor (so it appears in their dashboard). + - `requested_by` is preserved for audit purposes. + - Optional field overrides (starts_at, ends_at, mode, meeting_link, venue) + allow the mentor to adjust the proposed logistics before approving. + + On REJECT: + - The session moves from REQUESTED → REJECTED. + - The student can query their own requests to see the rejection. + """ + + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Mentor Session Request"], + description=( + "Approve or reject a student's session request. " + "Only mentors whose scope covers the session's entity may act on it. " + "When approving, optional overrides for starts_at, ends_at, mode, " + "meeting_link, and venue allow the mentor to adjust logistics." + ), + request=serializers.MentorStudentRequestVerifySerializer, + ) + @role_required([RoleType.MENTOR.value]) + def patch(self, request, session_id): + user_id = JWTUtils.fetch_user_id(request) + + # Fetch the session — must be in REQUESTED state and not deleted + session = MentorshipSession.objects.filter( + id=session_id, + status=MentorshipSession.Status.REQUESTED, + is_deleted=False, + ).first() + + if not session: + return CustomResponse( + general_message="Session request not found or is not in REQUESTED status." + ).get_failure_response(status_code=404) + + # ── Scope check: verify this mentor is eligible to act on this session ── + if not _mentor_can_act(user_id, session): + return CustomResponse( + general_message="You do not have permission to act on this session request." + ).get_failure_response(status_code=403) + + serializer = serializers.MentorStudentRequestVerifySerializer( + session, + data=request.data, + context={"user_id": user_id}, + ) + if serializer.is_valid(): + serializer.save() + action = serializer.validated_data["status"] + if action == "APPROVED": + msg = ( + "Session request approved. It is now pending admin scheduling. " + "You can manage it from your sessions dashboard." + ) + else: + msg = "Session request rejected." + + return CustomResponse(general_message=msg).get_success_response() + + return CustomResponse(message=serializer.errors).get_failure_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# Helper +# ───────────────────────────────────────────────────────────────────────────── + +def _mentor_can_act(user_id: str, session: MentorshipSession) -> bool: + """ + Returns True if the given user (mentor) is authorised to approve/reject + the session. All sessions are Interest-Group scoped, so eligibility is: + + - Global MENTOR → may act on anything. + - Any other tier → may act on an IG_SESSION request for an Interest + Group they actively mentor (active MENTOR UserIgLink), regardless of + tier. + """ + from db.task import UserIgLink + from db.user import MentorScopeGrant + from api.dashboard.mentor.dash_mentor_helper import get_mentor_scopes + + scopes = get_mentor_scopes(user_id) + + if not scopes: + return False + + # Global mentor can act on anything. + if (MentorScopeGrant.ScopeType.MENTOR, None) in scopes: + return True + + if session.session_type != MentorshipSession.SessionType.IG_SESSION: + return False + + return UserIgLink.objects.filter( + user_id=user_id, + ig_id=session.entity_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).exists() diff --git a/api/dashboard/mentor/task_serializers.py b/api/dashboard/mentor/task_serializers.py new file mode 100644 index 000000000..efbbd547e --- /dev/null +++ b/api/dashboard/mentor/task_serializers.py @@ -0,0 +1,164 @@ +import uuid + +from rest_framework import serializers + +from db.task import TaskList, InterestGroup, UserIgLink +from db.skill import Skill, TaskSkillLink + + +class MentorTaskCreateSerializer(serializers.ModelSerializer): + """ + Mirrors admin TaskModifySerializer field-for-field. + Locked fields (active, approval_status, requested_by, requested_at) + are injected by the view via serializer.save(**overrides). + """ + + class Meta: + model = TaskList + fields = ( + "hashtag", + "title", + "karma", + "usage_count", + "description", + "type", + "level", + "ig", + "created_by", + "updated_by", + ) + extra_kwargs = { + "ig": {"required": True, "allow_null": False}, + } + + def validate_hashtag(self, value): + """Global hashtag uniqueness — same rule as admin.""" + qs = TaskList.objects.filter(hashtag=value) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise serializers.ValidationError( + "A task with this hashtag already exists." + ) + return value + + def validate_ig(self, value): + """Mentor must be an active MENTOR member of the selected IG.""" + user_id = self.context.get("user_id") + if not UserIgLink.objects.filter( + user_id=user_id, + ig=value, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).exists(): + raise serializers.ValidationError( + "You are not assigned as a mentor for this Interest Group." + ) + return value + + +class MentorTaskUpdateSerializer(serializers.ModelSerializer): + """ + Partial update serializer — same writable fields as create. + The view injects approval_status / active / review-reset fields via save(). + """ + + class Meta: + model = TaskList + fields = ( + "hashtag", + "title", + "karma", + "usage_count", + "description", + "type", + "level", + "ig", + "updated_by", + ) + + def validate_hashtag(self, value): + """Exclude current instance from uniqueness check on edit.""" + qs = TaskList.objects.filter(hashtag=value) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise serializers.ValidationError( + "A task with this hashtag already exists." + ) + return value + + def validate_ig(self, value): + """Mentor must still belong to the IG even on edit.""" + user_id = self.context.get("user_id") + if not UserIgLink.objects.filter( + user_id=user_id, + ig=value, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).exists(): + raise serializers.ValidationError( + "You are not assigned as a mentor for this Interest Group." + ) + return value + + +class MentorTaskListSerializer(serializers.ModelSerializer): + """ + Read-only list/detail serializer — mirrors admin TaskListSerializer. + """ + channel = serializers.CharField(source="channel.name", required=False, default=None) + type = serializers.CharField(source="type.title") + level = serializers.CharField(source="level.name", required=False, default=None) + ig = serializers.CharField(source="ig.name", required=False, default=None) + org = serializers.CharField(source="org.title", required=False, default=None) + requested_by_name = serializers.CharField( + source="requested_by.full_name", required=False, default=None + ) + skills = serializers.SerializerMethodField() + + class Meta: + model = TaskList + fields = [ + "id", + "hashtag", + "discord_link", + "title", + "description", + "karma", + "channel", + "type", + "active", + "variable_karma", + "usage_count", + "level", + "org", + "ig", + "event", + "bonus_karma", + "bonus_time", + "approval_status", + "rejection_reason", + "reviewed_at", + "requested_by_name", + "requested_at", + "skills", + "created_at", + "updated_at", + ] + + def get_skills(self, obj): + """Return skills linked to this task — same as admin TaskListSerializer.""" + skill_links = TaskSkillLink.objects.filter(task_id=obj.id).select_related("skill") + return [ + {"id": link.skill.id, "name": link.skill.name, "code": link.skill.code} + for link in skill_links + ] + + +class MentorIGDropdownSerializer(serializers.ModelSerializer): + """Minimal IG serializer for the mentor-scoped dropdown.""" + + class Meta: + model = InterestGroup + fields = ["id", "name"] diff --git a/api/dashboard/mentor/task_views.py b/api/dashboard/mentor/task_views.py new file mode 100644 index 000000000..3383c70de --- /dev/null +++ b/api/dashboard/mentor/task_views.py @@ -0,0 +1,305 @@ +import json +import uuid + +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes, OpenApiResponse, inline_serializer +from rest_framework import serializers as s + +from db.task import TaskList, InterestGroup, UserIgLink +from db.skill import Skill, TaskSkillLink +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils, DateTimeUtils + +from .task_serializers import ( + MentorIGDropdownSerializer, + MentorTaskCreateSerializer, + MentorTaskListSerializer, + MentorTaskUpdateSerializer, +) + +def _save_task_skills(task_id, skill_ids, user_id): + """Clear and re-create TaskSkillLink rows — mirrors admin implementation.""" + TaskSkillLink.objects.filter(task_id=task_id).delete() + for skill_id in skill_ids: + if Skill.objects.filter(id=skill_id, is_active=True).exists(): + TaskSkillLink.objects.create( + id=str(uuid.uuid4()), + task_id=task_id, + skill_id=skill_id, + created_by_id=user_id, + ) + +class MentorIGDropdownAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Mentor Task"], + description=( + "Returns only the Interest Groups the authenticated mentor " + "belongs to (active MENTOR assignment). " + "Use this list to populate the IG selector when creating a task." + ), + responses={ + 200: inline_serializer( + "MentorIGDropdownResponse", + fields={ + "id": s.CharField(), + "name": s.CharField(), + }, + many=True, + ) + }, + ) + @role_required([RoleType.MENTOR.value]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + ig_ids = UserIgLink.objects.filter( + user_id=user_id, + assignment_type=UserIgLink.AssignmentType.MENTOR, + is_active=True, + ).values_list("ig_id", flat=True) + + igs = InterestGroup.objects.filter(id__in=ig_ids).values("id", "name") + return CustomResponse(response=list(igs)).get_success_response() + +class MentorTaskListCreateAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Mentor Task"], + description=( + "List all tasks submitted by the authenticated mentor " + "(filtered by requested_by). Supports pagination, search, and sort." + ), + parameters=[ + OpenApiParameter( + "approval_status", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + description="Filter by approval_status: pending | approved | rejected", + ), + ], + responses={ + 200: inline_serializer( + "MentorTaskListResponse", + fields={ + "data": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + }, + ) + }, + ) + @role_required([RoleType.MENTOR.value]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + queryset = TaskList.objects.select_related( + "channel", "type", "level", "ig", "org", "requested_by" + ).filter(requested_by_id=user_id) + + approval_status = request.query_params.get("approval_status") + if approval_status: + queryset = queryset.filter(approval_status=approval_status) + + paginated = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=[ + "hashtag", + "title", + "description", + "karma", + "ig__name", + "type__title", + "approval_status", + ], + sort_fields={ + "hashtag": "hashtag", + "title": "title", + "karma": "karma", + "ig": "ig__name", + "type": "type__title", + "approval_status": "approval_status", + "created_at": "created_at", + "updated_at": "updated_at", + }, + ) + + serializer = MentorTaskListSerializer( + paginated.get("queryset"), many=True + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated.get("pagination"), + ) + + @extend_schema( + tags=["Dashboard - Mentor Task"], + description=( + "Create a new task for an IG the mentor belongs to. " + "The task is saved with approval_status='pending' and active=False " + "until an admin approves it. " + "Accepts an optional skill_ids list (array of active skill UUIDs) " + "alongside the task fields — mirrors admin task creation behaviour." + ), + request=MentorTaskCreateSerializer, + responses={200: OpenApiResponse(description="Task submitted for approval.")}, + ) + @role_required([RoleType.MENTOR.value]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + mutable_data = request.data.copy() + mutable_data["created_by"] = user_id + mutable_data["updated_by"] = user_id + skill_ids = mutable_data.pop("skill_ids", None) + + if isinstance(skill_ids, str): + try: + skill_ids = json.loads(skill_ids) + except (json.JSONDecodeError, TypeError): + skill_ids = [] + + serializer = MentorTaskCreateSerializer( + data=mutable_data, + context={"user_id": user_id}, + ) + if not serializer.is_valid(): + return CustomResponse(message=serializer.errors).get_failure_response() + + task = serializer.save( + id=str(uuid.uuid4()), + approval_status="pending", + active=False, + requested_by_id=user_id, + requested_at=DateTimeUtils.get_current_utc_time(), + ) + + if skill_ids: + _save_task_skills(task.id, skill_ids, user_id) + + return CustomResponse( + general_message="Task submitted for approval." + ).get_success_response() + + +class MentorTaskDetailAPI(APIView): + permission_classes = [CustomizePermission] + + @extend_schema( + tags=["Dashboard - Mentor Task"], + description="Retrieve the detail of a specific task submitted by the mentor.", + responses={200: MentorTaskListSerializer}, + ) + @role_required([RoleType.MENTOR.value]) + def get(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + + task = TaskList.objects.select_related( + "channel", "type", "level", "ig", "org", "requested_by" + ).filter(id=task_id, requested_by_id=user_id).first() + + if not task: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404) + + serializer = MentorTaskListSerializer(task) + return CustomResponse(response=serializer.data).get_success_response() + + @extend_schema( + tags=["Dashboard - Mentor Task"], + description=( + "Update a task submitted by the mentor. " + "Regardless of the current approval_status, the task reverts to " + "'pending' and active=False after editing (triggering re-approval). " + "If the task was previously approved, it is deactivated until re-approved. " + "Accepts optional skill_ids to replace the current skill links." + ), + request=MentorTaskUpdateSerializer, + responses={200: OpenApiResponse(description="Task updated and re-submitted for approval.")}, + ) + @role_required([RoleType.MENTOR.value]) + def put(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + + task = TaskList.objects.filter( + id=task_id, requested_by_id=user_id + ).first() + + if not task: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404) + + mutable_data = request.data.copy() + mutable_data["updated_by"] = user_id + skill_ids = mutable_data.pop("skill_ids", None) + + if isinstance(skill_ids, str): + try: + skill_ids = json.loads(skill_ids) + except (json.JSONDecodeError, TypeError): + skill_ids = None + + serializer = MentorTaskUpdateSerializer( + task, + data=mutable_data, + partial=True, + context={"user_id": user_id}, + ) + if not serializer.is_valid(): + return CustomResponse(message=serializer.errors).get_failure_response() + + serializer.save( + approval_status="pending", + active=False, + rejection_reason=None, + reviewed_by_admin=None, + reviewed_at=None, + ) + + if skill_ids is not None: + _save_task_skills(task_id, skill_ids, user_id) + + return CustomResponse( + general_message="Task updated and re-submitted for approval." + ).get_success_response() + + @extend_schema( + tags=["Dashboard - Mentor Task"], + description=( + "Delete a task submitted by the mentor. " + "Only allowed when approval_status is 'pending'. " + "Approved or rejected tasks cannot be deleted." + ), + responses={200: OpenApiResponse(description="Task deleted successfully.")}, + ) + @role_required([RoleType.MENTOR.value]) + def delete(self, request, task_id): + user_id = JWTUtils.fetch_user_id(request) + + task = TaskList.objects.filter( + id=task_id, requested_by_id=user_id + ).first() + + if not task: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404) + + if task.approval_status != "pending": + return CustomResponse( + general_message=( + f"Cannot delete a task with status '{task.approval_status}'. " + "Only pending tasks can be deleted." + ) + ).get_failure_response() + + task.delete() + return CustomResponse( + general_message="Task deleted successfully." + ).get_success_response() diff --git a/api/dashboard/mentor/tests/__init__.py b/api/dashboard/mentor/tests/__init__.py new file mode 100644 index 000000000..f39e5e8d6 --- /dev/null +++ b/api/dashboard/mentor/tests/__init__.py @@ -0,0 +1 @@ +# Init diff --git a/api/dashboard/mentor/tests/test_mentor_dashboard.py b/api/dashboard/mentor/tests/test_mentor_dashboard.py new file mode 100644 index 000000000..54b2bd963 --- /dev/null +++ b/api/dashboard/mentor/tests/test_mentor_dashboard.py @@ -0,0 +1,65 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from db.user import User, UserMentor +from db.organization import Organization, UserOrganizationLink +from db.task import InterestGroup, UserIgLink + +@pytest.fixture +def mentor_setup(): + mentor_user = User.objects.create(id="mentor1", full_name="Test Mentor", email="mentor@test.com", muid="mentor@mulearn") + student_user = User.objects.create(id="student1", full_name="Test Student", email="student@test.com", muid="student@mulearn") + unrelated_user = User.objects.create(id="unrelated1", full_name="Unrelated", email="unrelated@test.com", muid="unrelated@mulearn") + + campus = Organization.objects.create(id="campus1", title="Test Campus", code="TC", org_type="College") + company = Organization.objects.create(id="company1", title="Test Company", code="TCOMP", org_type="Company") + ig = InterestGroup.objects.create(id="ig1", name="Test IG", code="TIG") + + UserMentor.objects.create(user=mentor_user, mentor_tier=UserMentor.MentorTier.CAMPUS_MENTOR, org=campus, status=UserMentor.Status.APPROVED, updated_by=mentor_user, created_by=mentor_user) + UserMentor.objects.create(user=mentor_user, mentor_tier=UserMentor.MentorTier.COMPANY_MENTOR, org=company, status=UserMentor.Status.APPROVED, updated_by=mentor_user, created_by=mentor_user) + UserIgLink.objects.create(user=mentor_user, ig=ig, assignment_type=UserIgLink.AssignmentType.MENTOR, created_by=mentor_user) + + UserOrganizationLink.objects.create(user=student_user, org=campus, verified=True, created_by=student_user) + + return {"mentor": mentor_user, "unrelated": unrelated_user} + +@pytest.mark.django_db +def test_dashboard_multi_scope_returns_array(client, mentor_setup): + url = reverse('mentor-overview') + client.force_login(mentor_setup["mentor"]) + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + scopes = response.json()['response']['scopes'] + + assert len(scopes) == 3 + scope_types = [s['scope_type'] for s in scopes] + assert "CAMPUS_MENTOR" in scope_types + assert "COMPANY_MENTOR" in scope_types + assert "IG_MENTOR" in scope_types + + campus_scope = next(s for s in scopes if s['scope_type'] == "CAMPUS_MENTOR") + assert campus_scope['metrics']['total_learners'] == 1 + +@pytest.mark.django_db +def test_dashboard_bola_mitigation(client, mentor_setup): + other_org = Organization.objects.create(id="other_org", title="Other Org", code="OO", org_type="College") + UserOrganizationLink.objects.create(user=mentor_setup["unrelated"], org=other_org, verified=True, created_by=mentor_setup["unrelated"]) + + url = reverse('mentor-overview') + f"?org_id={other_org.id}" + client.force_login(mentor_setup["mentor"]) + response = client.get(url) + + assert response.status_code == status.HTTP_200_OK + scopes = response.json()['response']['scopes'] + + for s in scopes: + assert s['scope_id'] != other_org.id + +@pytest.mark.django_db +def test_unauthorized_dashboard_access(client, mentor_setup): + url = reverse('mentor-overview') + client.force_login(mentor_setup["unrelated"]) + response = client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/api/dashboard/mentor/tests/test_session_recurrence.py b/api/dashboard/mentor/tests/test_session_recurrence.py new file mode 100644 index 000000000..1fdd509e5 --- /dev/null +++ b/api/dashboard/mentor/tests/test_session_recurrence.py @@ -0,0 +1,105 @@ +import pytest +from datetime import datetime, date, timedelta +from django.utils import timezone +from db.user import User, UserRoleLink, Role +from db.mentor import MentorshipSession +from utils.types import RoleType +from api.dashboard.mentor.session_recurrence_helper import generate_recurring_sessions +import uuid + +@pytest.fixture +def mentor_user(): + user = User.objects.create(id=str(uuid.uuid4()), full_name="Mentor Test", email="m@t.com", muid="m@mu") + return user + +@pytest.mark.django_db +def test_create_weekly_series(mentor_user): + start_time = timezone.now() + timedelta(days=1) + end_time = start_time + timedelta(hours=1) + + parent_session = MentorshipSession.objects.create( + id=str(uuid.uuid4()), + session_type=MentorshipSession.SessionType.IG_SESSION, + entity_id="ig1", + title="Weekly Meeting", + mode=MentorshipSession.Mode.ONLINE, + starts_at=start_time, + ends_at=end_time, + status=MentorshipSession.Status.PENDING_APPROVAL, + created_by=mentor_user, + updated_by=mentor_user, + + is_recurring=True, + recurrence_type=MentorshipSession.RecurrenceType.WEEKLY, + recurrence_interval=1, + recurrence_end_date=(start_time + timedelta(days=30)).date() + ) + + children = generate_recurring_sessions(parent_session) + + # 30 days = 4 weeks (4 child sessions + 1 parent) + assert len(children) == 4 + for child in children: + assert child.parent_session_id == parent_session.id + assert child.is_recurring is True + assert child.recurrence_type == MentorshipSession.RecurrenceType.WEEKLY + +@pytest.mark.django_db +def test_recurrence_hard_cap(mentor_user): + start_time = timezone.now() + timedelta(days=1) + end_time = start_time + timedelta(hours=1) + + parent_session = MentorshipSession.objects.create( + id=str(uuid.uuid4()), + session_type=MentorshipSession.SessionType.IG_SESSION, + entity_id="ig1", + title="Daily Meeting", + mode=MentorshipSession.Mode.ONLINE, + starts_at=start_time, + ends_at=end_time, + status=MentorshipSession.Status.PENDING_APPROVAL, + created_by=mentor_user, + updated_by=mentor_user, + + is_recurring=True, + recurrence_type=MentorshipSession.RecurrenceType.DAILY, + recurrence_interval=1, + # 5 years ahead + recurrence_end_date=(start_time + timedelta(days=365*5)).date() + ) + + children = generate_recurring_sessions(parent_session) + + # Should be capped at 50 + assert len(children) == 50 + +@pytest.mark.django_db +def test_monthly_edge_case(mentor_user): + # Jan 31st + start_time = datetime(2026, 1, 31, 10, 0, tzinfo=timezone.utc) + end_time = start_time + timedelta(hours=1) + + parent_session = MentorshipSession.objects.create( + id=str(uuid.uuid4()), + session_type=MentorshipSession.SessionType.IG_SESSION, + entity_id="ig1", + title="Monthly Meeting", + mode=MentorshipSession.Mode.ONLINE, + starts_at=start_time, + ends_at=end_time, + status=MentorshipSession.Status.PENDING_APPROVAL, + created_by=mentor_user, + updated_by=mentor_user, + + is_recurring=True, + recurrence_type=MentorshipSession.RecurrenceType.MONTHLY, + recurrence_interval=1, + recurrence_end_date=date(2026, 3, 5) + ) + + children = generate_recurring_sessions(parent_session) + + assert len(children) == 1 + # First generated session should be Feb 28th 2026 + assert children[0].starts_at.month == 2 + assert children[0].starts_at.day == 28 diff --git a/api/dashboard/mentor/urls.py b/api/dashboard/mentor/urls.py new file mode 100644 index 000000000..71d2bfe10 --- /dev/null +++ b/api/dashboard/mentor/urls.py @@ -0,0 +1,44 @@ +from django.urls import path +from . import mentor_views, session_views, availability_views, participant_views, task_views, student_requests_views + + +urlpatterns = [ + path('public/profile//', mentor_views.MentorPublicProfileAPI.as_view(), name='mentor-public-profile'), + path('public/availability//', availability_views.MentorPublicAvailabilityAPI.as_view(), name='mentor-public-availability'), + path('overview/', mentor_views.MentorOverviewAPI.as_view(), name='mentor-overview'), + path('register/', mentor_views.MentorRegistrationAPI.as_view(), name='mentor-register'), + path('status/', mentor_views.MentorStatusAPI.as_view(), name='mentor-status'), + path('profile/', mentor_views.MentorProfileAPI.as_view(), name='mentor-profile'), + path('activity/', mentor_views.MentorActivityListAPI.as_view(), name='mentor-activity'), + path('list/', mentor_views.MentorListAPI.as_view(), name='mentor-list'), + path('verify//', mentor_views.MentorVerifyAPI.as_view(), name='mentor-verify'), + path('detail//', mentor_views.MentorDetailAPI.as_view(), name='mentor-detail'), + path('session/create/', session_views.MentorSessionCreateAPI.as_view(), name='mentor-session-create'), + path('session/list/', session_views.MentorSessionListAPI.as_view(), name='mentor-session-list'), + path('session/list//', session_views.MentorSessionListAPI.as_view(), name='mentor-session-detail'), + path('session/update//', session_views.MentorSessionUpdateAPI.as_view(), name='mentor-session-update'), + path('session/available/', session_views.AvailableSessionListAPI.as_view(), name='available-session-list'), + path('session/admin/list/', session_views.AdminSessionListAPI.as_view(), name='admin-session-list'), + path('session/admin/verify//', session_views.AdminSessionVerifyAPI.as_view(), name='admin-session-verify'), + path('availability/', availability_views.MentorAvailabilitySlotAPI.as_view(), name='mentor-availability-list-create'), + path('availability//', availability_views.MentorAvailabilitySlotAPI.as_view(), name='mentor-availability-update-delete'), + path('session/participation/join//', participant_views.SessionJoinAPI.as_view(), name='session-join'), + path('session/participant/history/', participant_views.UserSessionHistoryAPI.as_view(), name='user-session-history'), + path('session/participant/add//', participant_views.MentorAddParticipantAPI.as_view(), name='mentor-participant-add'), + path('session/participant/list//', participant_views.MentorParticipantListAPI.as_view(), name='mentor-participant-list'), + path('session/participant/update//', participant_views.MentorParticipantUpdateAPI.as_view(), name='mentor-participant-update'), + path('session/participant/feedback//', participant_views.ParticipantFeedbackAPI.as_view(), name='participant-feedback'), + path('tasks/ig-dropdown/', task_views.MentorIGDropdownAPI.as_view(), name='mentor-task-ig-dropdown'), + path('tasks/', task_views.MentorTaskListCreateAPI.as_view(), name='mentor-task-list-create'), + path('tasks//', task_views.MentorTaskDetailAPI.as_view(), name='mentor-task-detail'), + path('admin/assign/', mentor_views.AdminAssignMentorAPI.as_view(), name='admin-assign-mentor'), + path('admin/assign//', mentor_views.AdminAssignMentorAPI.as_view(), name='admin-revoke-mentor'), + path('/grants/', mentor_views.MentorScopeGrantListAPI.as_view(), name='mentor-grant-list'), + path('/grants//', mentor_views.MentorScopeGrantRevokeAPI.as_view(), name='mentor-grant-revoke'), + + # ── Student session requests ───────────────────────────────────────────── + path('session/student/request/', student_requests_views.StudentSessionRequestAPI.as_view(), name='student-session-request'), + path('session/student/my-requests/', student_requests_views.StudentSessionRequestListAPI.as_view(), name='student-session-request-list'), + path('session/student-requests/', student_requests_views.MentorStudentRequestListAPI.as_view(), name='mentor-student-request-list'), + path('session/student-requests//verify/', student_requests_views.MentorStudentRequestVerifyAPI.as_view(), name='mentor-student-request-verify'), +] diff --git a/api/dashboard/organisation/organisation_views.py b/api/dashboard/organisation/organisation_views.py index 93def4775..e02b54521 100644 --- a/api/dashboard/organisation/organisation_views.py +++ b/api/dashboard/organisation/organisation_views.py @@ -35,12 +35,19 @@ OrganizationVerifySerializer, UnverifiedOrganizationsSerializer, ) +from drf_spectacular.utils import extend_schema, OpenApiResponse class InstitutionPostUpdateDeleteAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Create Institution Post Update Delete.", + request=InstitutionCreateUpdateSerializer, + responses={200: InstitutionCreateUpdateSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -65,6 +72,11 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Update Institution Post Update Delete.", + responses={200: InstitutionCreateUpdateSerializer}, + ) def put(self, request, org_code): user_id = JWTUtils.fetch_user_id(request) @@ -97,7 +109,7 @@ def put(self, request, org_code): ) if ( - request.data.get("orgType") != OrganizationType.COMMUNITY.value + request.data.get("org_type") != OrganizationType.COMMUNITY.value and old_type == OrganizationType.COMMUNITY.value ): DiscordWebhooks.general_updates( @@ -108,7 +120,7 @@ def put(self, request, org_code): if ( old_type != OrganizationType.COMMUNITY.value - and request.data.get("orgType") == OrganizationType.COMMUNITY.value + and request.data.get("org_type") == OrganizationType.COMMUNITY.value ): title = request.data.get("title") or old_title DiscordWebhooks.general_updates( @@ -122,6 +134,9 @@ def put(self, request, org_code): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Organisation'], description="Delete Institution Post Update Delete.", + responses={200: InstitutionCreateUpdateSerializer}, + ) def delete(self, request, org_code): if not (organisation := Organization.objects.filter(code=org_code).first()): return CustomResponse( @@ -146,6 +161,11 @@ def delete(self, request, org_code): class InstitutionAPI(APIView): + @extend_schema( + tags=['Dashboard - Organisation'], + description="Retrieve Institution.", + responses={200: InstitutionSerializer}, + ) def get(self, request, org_type, district_id=None): if district_id: organisations = Organization.objects.filter( @@ -208,15 +228,20 @@ class InstitutionCSVAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Retrieve Institution C S V.", + responses={200: InstitutionSerializer}, + ) def get(self, request, org_type): organizations = ( Organization.objects.filter(org_type=org_type) .select_related( "affiliation", - # "district__zone__state__country", - # "district__zone__state", - # "district__zone", - # "district", + "district__zone__state__country", + "district__zone__state", + "district__zone", + "district", ) .prefetch_related( Prefetch( @@ -239,6 +264,9 @@ class InstitutionDetailsAPI(APIView): RoleType.ADMIN.value, ] ) + @extend_schema(tags=['Dashboard - Organisation'], description="Retrieve Institution Details.", + responses={200: InstitutionSerializer}, + ) def get(self, request, org_code): organization = ( Organization.objects.filter(code=org_code) @@ -265,6 +293,11 @@ def get(self, request, org_code): class GetInstitutionsAPI(APIView): + @extend_schema( + tags=['Dashboard - Organisation'], + description="Retrieve Get Institutions.", + responses={200: InstitutionSerializer}, + ) def get(self, request, org_type, district_id=None): if district_id: organisations = Organization.objects.filter( @@ -290,6 +323,11 @@ class AffiliationGetPostUpdateDeleteAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Retrieve Affiliation Get Post Update Delete.", + responses={200: AffiliationSerializer}, + ) def get(self, request): affiliation = OrgAffiliation.objects.all() paginated_queryset = CommonUtils.get_paginated_queryset( @@ -303,6 +341,12 @@ def get(self, request): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Create Affiliation Get Post Update Delete.", + request=AffiliationCreateUpdateSerializer, + responses={200: AffiliationSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -323,6 +367,11 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Update Affiliation Get Post Update Delete.", + responses={200: AffiliationCreateUpdateSerializer}, + ) def put(self, request, affiliation_id): user_id = JWTUtils.fetch_user_id(request) @@ -347,6 +396,9 @@ def put(self, request, affiliation_id): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Organisation'], description="Delete Affiliation Get Post Update Delete.", + responses={200: AffiliationSerializer}, + ) def delete(self, request, affiliation_id): affiliation = OrgAffiliation.objects.filter(id=affiliation_id).first() @@ -366,6 +418,11 @@ class DepartmentAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Retrieve Department.", + responses={200: DepartmentSerializer}, + ) def get(self, request, dept_id=None): if dept_id: departments = Department.objects.filter(id=dept_id) @@ -383,6 +440,12 @@ def get(self, request, dept_id=None): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Create Department.", + request=DepartmentSerializer, + responses={200: DepartmentSerializer}, + ) def post(self, request): serializer = DepartmentSerializer( data=request.data, context={"request": request} @@ -398,6 +461,11 @@ def post(self, request): return CustomResponse(response=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Update Department.", + responses={200: DepartmentSerializer}, + ) def put(self, request, department_id): department = Department.objects.get(id=department_id) @@ -415,6 +483,9 @@ def put(self, request, department_id): return CustomResponse(response=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Organisation'], description="Delete Department.", + responses={200: DepartmentSerializer}, + ) def delete(self, request, department_id): department = Department.objects.get(id=department_id) @@ -426,6 +497,9 @@ def delete(self, request, department_id): class AffiliationListAPI(APIView): @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Organisation'], description="Retrieve Affiliation List.", + responses={200: AffiliationSerializer}, + ) def get(self, request): affiliation = OrgAffiliation.objects.all().values("id", "title") @@ -438,6 +512,11 @@ class InstitutionPrefillAPI(APIView): RoleType.ADMIN.value, ] ) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Retrieve Institution Prefill.", + responses={200: InstitutionPrefillSerializer}, + ) def get(self, request, org_code): organization = Organization.objects.filter(code=org_code).first() @@ -450,6 +529,9 @@ class OrganizationMergerView(APIView): permission_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Organisation'], description="Retrieve Organization Merger.", + responses={200: OrganizationMergerSerializer}, + ) def get(self, request, organisation_id): try: destination = Organization.objects.get(pk=organisation_id) @@ -467,6 +549,9 @@ def get(self, request, organisation_id): ).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Organisation'], description="Partially update Organization Merger.", + responses={200: OrganizationMergerSerializer}, + ) def patch(self, request, organisation_id): try: destination = Organization.objects.get(pk=organisation_id) @@ -488,6 +573,12 @@ def patch(self, request, organisation_id): class OrganizationKarmaTypeGetPostPatchDeleteAPI(APIView): + @extend_schema( + tags=['Dashboard - Organisation'], + description="Create Organization Karma Type Get Post Patch Delete.", + request=OrganizationKarmaTypeGetPostPatchDeleteSerializer, + responses={200: OrganizationKarmaTypeGetPostPatchDeleteSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -505,6 +596,12 @@ def post(self, request): class OrganizationKarmaLogGetPostPatchDeleteAPI(APIView): + @extend_schema( + tags=['Dashboard - Organisation'], + description="Create Organization Karma Log Get Post Patch Delete.", + request=OrganizationKarmaLogGetPostPatchDeleteSerializer, + responses={200: OrganizationKarmaLogGetPostPatchDeleteSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) @@ -525,6 +622,9 @@ def post(self, request): class OrganisationBaseTemplateAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Organisation'], description="Retrieve Organisation Base Template.", + responses={200: OpenApiResponse(description="XLSX file download")}, + ) def get(self, request): wb = load_workbook("./excel-templates/organisation_base_template.xlsx") ws = wb["Data Definitions"] @@ -559,6 +659,12 @@ class OrganisationImportAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Organisation'], + description="Create Organisation Import.", + request=OrganizationImportSerializer, + responses={200: OrganizationImportSerializer}, + ) def post(self, request): try: file_obj = request.FILES["organisation_list"] @@ -707,6 +813,9 @@ def post(self, request): class TransferAPI(APIView): + @extend_schema(tags=['Dashboard - Organisation'], description="Create Transfer.", + responses={200: OpenApiResponse(description="Organisation transfer success message")}, + ) def post(self, request): from_code = request.data.get("from_id") to_code = request.data.get("to_id") @@ -729,6 +838,11 @@ def post(self, request): class UnverifiedOrganizationsListAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Organisation'], + description="Retrieve Unverified Organizations List.", + responses={200: UnverifiedOrganizationsSerializer}, + ) def get(self, request): unverified_orgs = ( UnverifiedOrganization.objects.select_related("created_by", "department") @@ -742,6 +856,12 @@ def get(self, request): class VerifyOrganizationAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Organisation'], + description="Create Verify Organization.", + request=OrganizationVerifySerializer, + responses={200: OrganizationVerifySerializer}, + ) def post(self, request, uorg_id): user_id = JWTUtils.fetch_user_id(request) unverifed_org = UnverifiedOrganization.objects.filter(id=uorg_id).first() diff --git a/api/dashboard/organisation/serializers.py b/api/dashboard/organisation/serializers.py index 46bcb875d..005bef349 100644 --- a/api/dashboard/organisation/serializers.py +++ b/api/dashboard/organisation/serializers.py @@ -47,7 +47,7 @@ class Meta: ] def get_user_count(self, obj): - return obj.user_organization_link_org.annotate(user_count=Count("user")).count() + return obj.user_organization_link_org.filter(verified=True).count() # class InstitutionSerializer(serializers.ModelSerializer): @@ -107,7 +107,7 @@ class Meta: class InstitutionCreateUpdateSerializer(serializers.ModelSerializer): - district = serializers.CharField(required=False) + district = serializers.CharField(required=True) class Meta: model = Organization @@ -127,12 +127,26 @@ def create(self, validated_data): def update(self, instance, validated_data): user_id = self.context.get("user_id") + + old_org_type = instance.org_type + new_org_type = validated_data.get("org_type", old_org_type) + instance.title = validated_data.get("title", instance.title) instance.code = validated_data.get("code", instance.code) + instance.org_type = new_org_type instance.affiliation = validated_data.get("affiliation", instance.affiliation) instance.district = validated_data.get("district", instance.district) instance.updated_by_id = user_id instance.save() + + if old_org_type != new_org_type: + if new_org_type == OrganizationType.COLLEGE.value: + College.objects.get_or_create( + org=instance, defaults={'created_by_id': user_id, 'updated_by_id': user_id} + ) + elif old_org_type == OrganizationType.COLLEGE.value: + College.objects.filter(org=instance).delete() + return instance def validate_org_type(self, organization): diff --git a/api/dashboard/profile/profile_serializer.py b/api/dashboard/profile/profile_serializer.py index d86bbbb09..0db570a28 100644 --- a/api/dashboard/profile/profile_serializer.py +++ b/api/dashboard/profile/profile_serializer.py @@ -2,10 +2,10 @@ from decouple import config as decouple_config from django.db import transaction -from django.db.models import F, Sum, Q +from django.db.models import F, Sum, Q, Case, When, Value, CharField, Exists, OuterRef from rest_framework import serializers from rest_framework.serializers import ModelSerializer - +from db.task import UserIgLvlLink from db.organization import UserOrganizationLink, District from db.task import ( InterestGroup, @@ -15,8 +15,9 @@ Wallet, UserIgLink, UserLvlLink, + UserIgLvlLink, ) -from db.user import User, UserSettings, Socials +from db.user import User, UserSettings, Socials, UserRoleLink from utils.exception import CustomException from utils.permission import JWTUtils from utils.types import ( @@ -25,6 +26,7 @@ MainRoles, WebHookActions, WebHookCategory, + UnitType, ) from utils.utils import DateTimeUtils, DiscordWebhooks @@ -46,6 +48,12 @@ class Meta: fields = ["profile_pic"] +class UserCoverPicSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["cover_pic"] + + class UserProfileSerializer(serializers.ModelSerializer): joined = serializers.DateTimeField(source="created_at") level = serializers.CharField(source="user_lvl_link_user.level.name", default=None) @@ -54,6 +62,8 @@ class UserProfileSerializer(serializers.ModelSerializer): ) karma = serializers.IntegerField(source="wallet_user.karma", default=None) roles = serializers.SerializerMethodField() + role_verification = serializers.SerializerMethodField() + lead_enabler_verified = serializers.SerializerMethodField() college_id = serializers.SerializerMethodField() college_code = serializers.SerializerMethodField() rank = serializers.SerializerMethodField() @@ -71,6 +81,8 @@ class Meta: "gender", "muid", "roles", + "role_verification", + "lead_enabler_verified", "college_id", "college_code", "org_district_id", @@ -79,6 +91,7 @@ class Meta: "karma_distribution", "level", "profile_pic", + "cover_pic", "interest_groups", "is_public", "percentile", @@ -88,7 +101,7 @@ def _get_user_org_link(self, obj, org_type): if not getattr(self, "user_org_link", None): self.user_org_link = obj.user_organization_link_user.filter( org__org_type=org_type - ).first() + ).order_by("-created_at", "-id").first() return self.user_org_link def _get_org_type(self, obj): @@ -113,10 +126,24 @@ def get_percentile(self, obj): def get_roles(self, obj): if "role_values" in self.context: return self.context["role_values"] - role_values = list({link.role.title for link in obj.user_role_link_user.all()}) + + # Use explicitly prefetched roles to prevent lazy DB queries + role_links = getattr(obj, "prefetched_roles", obj.user_role_link_user.all()) + role_values = list({link.role.title for link in role_links}) + self.context["role_values"] = role_values return role_values + def get_role_verification(self, obj): + role_links = getattr(obj, "prefetched_roles", obj.user_role_link_user.all()) + return [ + { + "role": link.role.title, + "is_verified": link.verified + } + for link in role_links + ] + def get_college_id(self, obj): org_type = self._get_org_type(obj) user_org_link = self._get_user_org_link(obj, org_type) @@ -146,13 +173,13 @@ def get_rank(self, obj): user__user_role_link_user__verified=True, user__user_role_link_user__role__title=RoleType.MENTOR.value, karma__gte=user_karma, - ).order_by("-karma", "-updated_at", "created_at") + ).order_by("-karma", "-updated_at", "created_at").distinct() elif RoleType.ENABLER.value in roles: ranks = Wallet.objects.filter( user__user_role_link_user__verified=True, user__user_role_link_user__role__title=RoleType.ENABLER.value, karma__gte=user_karma, - ).order_by("-karma", "-updated_at", "created_at") + ).order_by("-karma", "-updated_at", "created_at").distinct() else: ranks = ( Wallet.objects.filter(karma__gte=user_karma) @@ -165,39 +192,113 @@ def get_rank(self, obj): ) ) .order_by("-karma", "-updated_at", "created_at") + .distinct() ) ranks = list(ranks.values_list("user_id", flat=True)) - return ranks.index(obj.id) + 1 + try: + return ranks.index(obj.id) + 1 + except ValueError: + return None def get_karma_distribution(self, obj): + # Exists subqueries to safely check creator's roles WITHOUT joining, + # which would cause duplicate rows and multiply karma sums incorrectly. + is_mentor = Exists( + UserRoleLink.objects.filter( + user=OuterRef("task__created_by"), + role__title=RoleType.MENTOR.value + ) + ) + is_intern = Exists( + UserRoleLink.objects.filter( + user=OuterRef("task__created_by"), + role__title=RoleType.INTERN.value + ) + ) + is_ig_lead = Exists( + UserRoleLink.objects.filter( + user=OuterRef("task__created_by"), + role__title=RoleType.IG_LEAD.value + ) + ) + return ( KarmaActivityLog.objects.filter(user=obj, appraiser_approved=True) - .values(task_type=F("task__type__title")) + # Annotate role flags first (Exists = no join, no duplication) + .annotate( + is_mentor=is_mentor, + is_intern=is_intern, + is_ig_lead=is_ig_lead, + ) + # Then bucket using priority order: + # 1. Events Task (task linked to an event) + # 2. IG Task (task linked to an IG, or creator is IG Lead) + # 3. Mentor Task (task created by a Mentor) + # 4. Intern Task (task created by an Intern) + # 5. Other Task (everything else) + .annotate( + bucket=Case( + When(task__event_fk__isnull=False, then=Value("Events Task")), + When( + Q(task__ig__isnull=False) | Q(is_ig_lead=True), + then=Value("IG Task") + ), + When(is_mentor=True, then=Value("Mentor Task")), + When(is_intern=True, then=Value("Intern Task")), + default=Value("Other Task"), + output_field=CharField() + ) + ) + .values(task_type=F("bucket")) .annotate(karma=Sum("karma")) - .order_by() + .order_by("-karma") ) def get_interest_groups(self, obj): + + # Get all IGs where user has a level entry (has interacted with this IG) + user_ig_levels = UserIgLvlLink.objects.filter(user=obj).select_related('ig', 'level') + + # Get user's currently selected IGs + selected_ig_ids = set( + UserIgLink.objects.filter(user=obj, is_active=True, assignment_type=UserIgLink.AssignmentType.LEARNER).values_list('ig_id', flat=True) + ) + interest_groups = [] - for ig_link in UserIgLink.objects.filter(user=obj): + for ig_level_link in user_ig_levels: + # Calculate IG-specific karma total_ig_karma = ( - 0 - if KarmaActivityLog.objects.filter( - task__ig=ig_link.ig, user=obj, appraiser_approved=True + KarmaActivityLog.objects.filter( + task__ig=ig_level_link.ig, user=obj, appraiser_approved=True ) .aggregate(Sum("karma")) - .get("karma__sum") - is None - else KarmaActivityLog.objects.filter( - task__ig=ig_link.ig, user=obj, appraiser_approved=True - ) - .aggregate(Sum("karma")) - .get("karma__sum") - ) - interest_groups.append( - {"id": ig_link.ig.id, "name": ig_link.ig.name, "karma": total_ig_karma} + .get("karma__sum") or 0 ) + + interest_groups.append({ + "id": ig_level_link.ig.id, + "name": ig_level_link.ig.name, + "karma": total_ig_karma, + "selected": ig_level_link.ig.id in selected_ig_ids, + "level": { + "count": ig_level_link.level.level_order, + "unit": UnitType.LEVEL.value + } + }) + return interest_groups + + def get_lead_enabler_verified(self, obj): + role_links = getattr(obj, "prefetched_roles", obj.user_role_link_user.all()) + for link in role_links: + role_title = getattr(getattr(link, "role", None), "title", None) + if ( + role_title == RoleType.LEAD_ENABLER.value + and getattr(link, "verified", False) + and getattr(link, "is_active", False) + ): + return True + return False class UserLevelSerializer(serializers.ModelSerializer): @@ -279,13 +380,13 @@ def get_rank(self, obj): user__user_role_link_user__verified=True, user__user_role_link_user__role__title=RoleType.MENTOR.value, karma__gte=user_karma, - ).order_by("-karma", "-updated_at", "created_at") + ).order_by("-karma", "-updated_at", "created_at").distinct() elif RoleType.ENABLER.value in roles: ranks = Wallet.objects.filter( user__user_role_link_user__verified=True, user__user_role_link_user__role__title=RoleType.ENABLER.value, karma__gte=user_karma, - ).order_by("-karma", "-updated_at", "created_at") + ).order_by("-karma", "-updated_at", "created_at").distinct() else: ranks = ( Wallet.objects.filter(karma__gte=user_karma) @@ -298,10 +399,14 @@ def get_rank(self, obj): ) ) .order_by("-karma", "-updated_at", "created_at") + .distinct() ) ranks = list(ranks.values_list("user_id", flat=True)) - return ranks.index(obj.id) + 1 + try: + return ranks.index(obj.id) + 1 + except ValueError: + return None def get_karma(self, obj): return total_karma.karma if (total_karma := obj.wallet_user) else None @@ -331,6 +436,13 @@ def update(self, instance, validated_data): class UserProfileEditSerializer(serializers.ModelSerializer): communities = serializers.ListField(write_only=True) + district_id = serializers.PrimaryKeyRelatedField( + queryset=District.objects.all(), + source="district", + write_only=True, + required=False, + allow_null=True, + ) def to_representation(self, instance): data = super().to_representation(instance) @@ -343,10 +455,35 @@ def to_representation(self, instance): district = instance.district if district: - data["district"] = district.name + zone = district.zone + state = zone.state if zone else None + country = state.country if state else None + data["district"] = { + "id": district.id, + "name": district.name, + "state": { + "id": state.id if state else None, + "name": state.name if state else None, + "country": { + "id": country.id if country else None, + "name": country.name if country else None, + }, + }, + } else: data["district"] = None + college_link = instance.user_organization_link_user.filter( + org__org_type=OrganizationType.COLLEGE.value + ).select_related("department").first() + if college_link and college_link.department: + data["department"] = { + "id": college_link.department.id, + "title": college_link.department.title, + } + else: + data["department"] = None + return data def update(self, instance, validated_data): @@ -381,7 +518,7 @@ class Meta: "communities", "gender", "dob", - "district", + "district_id", ] @@ -399,8 +536,13 @@ class UserIgEditSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): with transaction.atomic(): - instance.user_ig_link_user.all().delete() ig_details = set(validated_data.pop("interest_group", [])) + if len(ig_details) > 3: + raise CustomException("Cannot add more than 3 interest groups") + # Only remove LEARNER-type links; preserve MENTOR/LEAD/MODERATOR assignments. + instance.user_ig_link_user.filter( + assignment_type=UserIgLink.AssignmentType.LEARNER + ).delete() user_ig_links = [ UserIgLink( id=uuid.uuid4(), @@ -408,12 +550,37 @@ def update(self, instance, validated_data): ig_id=ig_data, created_by=instance, created_at=DateTimeUtils.get_current_utc_time(), + assignment_type=UserIgLink.AssignmentType.LEARNER, + is_active=True, ) for ig_data in ig_details ] - if len(user_ig_links) > 3: - raise CustomException("Cannot add more than 3 interest groups") UserIgLink.objects.bulk_create(user_ig_links) + + # Initialize IG levels for newly added IGs + from django.db import connection + for ig_id in ig_details: + # Get level 1 ID + with connection.cursor() as cursor: + cursor.execute("SELECT id FROM level WHERE level_order = 1 LIMIT 1") + level_1_id = cursor.fetchone() + if level_1_id: + # UPSERT: Insert level 1 if doesn't exist, do nothing if exists + cursor.execute(""" + INSERT INTO user_ig_lvl_link (id, user_id, ig_id, level_id, created_by, created_at, updated_by, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE updated_at = updated_at + """, [ + str(uuid.uuid4()), + str(instance.id), + str(ig_id), + level_1_id[0], + str(instance.id), + DateTimeUtils.get_current_utc_time(), + str(instance.id), + DateTimeUtils.get_current_utc_time() + ]) + return super().update(instance, validated_data) class Meta: diff --git a/api/dashboard/profile/profile_view.py b/api/dashboard/profile/profile_view.py index 4094f10c8..d6ed22147 100644 --- a/api/dashboard/profile/profile_view.py +++ b/api/dashboard/profile/profile_view.py @@ -1,6 +1,9 @@ from datetime import datetime, timedelta from io import BytesIO +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers as s import qrcode import requests from django.conf import settings @@ -12,9 +15,11 @@ from rest_framework.views import APIView from django.db.models import Sum from django.core.cache import cache +from db.user import Role, UserRoleLink + from django.utils.timezone import now -from db.organization import UserOrganizationLink +from db.organization import UserOrganizationLink, Department from db.task import InterestGroup, KarmaActivityLog, Level, UserIgLink, UserLvlLink from db.user import ( Role, @@ -44,9 +49,32 @@ class UserProfileEditView(APIView): authentication_classes = [CustomizePermission] + @staticmethod + def _get_user(user_id): + """Fetch the user with all data needed by UserProfileEditSerializer + pre-loaded in a single query set, avoiding per-call SQL round-trips + for communities and department inside to_representation.""" + return ( + User.objects + .select_related('district__zone__state__country') + .prefetch_related( + Prefetch( + 'user_organization_link_user', + queryset=UserOrganizationLink.objects.select_related('department'), + ) + ) + .filter(id=user_id) + .first() + ) + + @extend_schema( + tags=['Dashboard - Profile'], + description="Retrieve User Profile Edit.", + responses={200: profile_serializer.UserProfileEditSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) - user = User.objects.filter(id=user_id).first() + user = self._get_user(user_id) if not user: return CustomResponse( @@ -57,9 +85,19 @@ def get(self, request): return CustomResponse(response=serializer.data).get_success_response() + @extend_schema( + tags=['Dashboard - Profile'], + description="Partially update User Profile Edit.", + responses={200: profile_serializer.UserProfileEditSerializer}, + ) def patch(self, request): user_id = JWTUtils.fetch_user_id(request) - user = User.objects.get(id=user_id) + user = self._get_user(user_id) + + if not user: + return CustomResponse( + general_message="User Not Exists" + ).get_failure_response() serializer = profile_serializer.UserProfileEditSerializer( user, data=request.data, partial=True @@ -68,6 +106,10 @@ def patch(self, request): if serializer.is_valid(): serializer.save() + # Re-fetch to reflect any updates made by save() (e.g. district FK). + user = self._get_user(user_id) + serializer = profile_serializer.UserProfileEditSerializer(user, many=False) + DiscordWebhooks.general_updates( WebHookCategory.USER_NAME.value, WebHookActions.UPDATE.value, @@ -78,6 +120,9 @@ def patch(self, request): return CustomResponse(response=serializer.errors).get_failure_response() + @extend_schema(tags=['Dashboard - Profile'], description="Delete User Profile Edit.", + responses={200: profile_serializer.UserProfileEditSerializer}, + ) def delete(self, request): user_id = JWTUtils.fetch_user_id(request) user = User.objects.get(id=user_id).delete() @@ -87,18 +132,120 @@ def delete(self, request): ).get_success_response() +class UserProfileCoverView(APIView): + authentication_classes = [CustomizePermission] + + MAX_COVER_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB + + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve User Profile Cover.", + responses={200: profile_serializer.UserProfileSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + user = User.objects.filter(id=user_id).first() + + if not user: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + return CustomResponse( + response={"cover_pic": user.cover_pic} + ).get_success_response() + + @extend_schema(tags=['Dashboard - Profile'], description="Create User Profile Cover.", + responses={200: profile_serializer.UserProfileSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + user = User.objects.filter(id=user_id).first() + + if not user: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + cover = request.FILES.get("cover") + + if cover is None: + return CustomResponse( + general_message="No cover image provided" + ).get_failure_response() + + if not cover.content_type.startswith("image/"): + return CustomResponse( + general_message="Expected an image file" + ).get_failure_response() + + if cover.size > self.MAX_COVER_SIZE_BYTES: + return CustomResponse( + general_message="Cover image must be under 5 MB" + ).get_failure_response() + + fs = FileSystemStorage() + filename = f"user/cover/{user_id}.png" + + if fs.exists(filename): + fs.delete(filename) + + fs.save(filename, cover) + uploaded_url = user.cover_pic + + return CustomResponse( + response={"cover_pic": uploaded_url} + ).get_success_response() + + @extend_schema(tags=['Dashboard - Profile'], description="Delete User Profile Cover.", + responses={200: profile_serializer.UserProfileSerializer}, + ) + def delete(self, request): + user_id = JWTUtils.fetch_user_id(request) + user = User.objects.filter(id=user_id).first() + + if not user: + return CustomResponse( + general_message="User not found" + ).get_failure_response() + + fs = FileSystemStorage() + filename = f"user/cover/{user_id}.png" + + if not fs.exists(filename): + return CustomResponse( + general_message="No cover image found" + ).get_failure_response() + + fs.delete(filename) + + return CustomResponse( + general_message="Cover image removed successfully" + ).get_success_response() + + class UserIgEditView(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Profile'], + description="Retrieve User Ig Edit.", + responses={200: profile_serializer.UserIgListSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) - user_ig = InterestGroup.objects.filter(user_ig_link_ig__user_id=user_id).all() + user_ig = InterestGroup.objects.filter(user_ig_link_ig__user_id=user_id, + user_ig_link_ig__is_active=True, + user_ig_link_ig__assignment_type=UserIgLink.AssignmentType.LEARNER).all() serializer = profile_serializer.UserIgListSerializer(user_ig, many=True) return CustomResponse(response=serializer.data).get_success_response() + @extend_schema( + tags=['Dashboard - Profile'], + description="Partially update User Ig Edit.", + responses={200: profile_serializer.UserIgEditSerializer}, + ) def patch(self, request): user_id = JWTUtils.fetch_user_id(request) user = User.objects.get(id=user_id) @@ -111,6 +258,8 @@ def patch(self, request): return CustomResponse(response=serializer.errors).get_failure_response() serializer.save() + + DiscordWebhooks.general_updates( WebHookCategory.USER.value, WebHookActions.UPDATE.value, @@ -122,7 +271,18 @@ def patch(self, request): class UserProfileAPI(APIView): + @extend_schema( + tags=['Dashboard - Profile'], + description="Retrieve User Profile.", + responses={200: profile_serializer.UserProfileSerializer}, + ) def get(self, request, muid=None): + if not muid: + JWTUtils.is_jwt_authenticated(request) + user_muid = JWTUtils.fetch_muid(request) + else: + user_muid = muid + user = ( User.objects.prefetch_related( Prefetch( @@ -133,10 +293,8 @@ def get(self, request, muid=None): ), Prefetch( "user_role_link_user", - queryset=UserRoleLink.objects.select_related("role").filter( - verified=True - ), - to_attr="verified_roles", + queryset=UserRoleLink.objects.select_related("role"), + to_attr="prefetched_roles", ), Prefetch( "user_ig_link_user", @@ -144,26 +302,27 @@ def get(self, request, muid=None): ), ) .select_related("wallet_user") - .get(muid=muid or JWTUtils.fetch_muid(request)) + .get(muid=user_muid) ) if muid: user_settings = UserSettings.objects.filter(user_id=user).first() - if not user_settings.is_public: return CustomResponse( general_message="Private Profile" ).get_failure_response() - else: - JWTUtils.is_jwt_authenticated(request) - serializer = profile_serializer.UserProfileSerializer(user, many=False) return CustomResponse(response=serializer.data).get_success_response() class UserLogAPI(APIView): + @extend_schema( + tags=['Dashboard - Profile'], + description="Retrieve User Log.", + responses={200: profile_serializer.UserLogSerializer}, + ) def get(self, request, muid=None): if muid is not None: user = User.objects.filter(muid=muid).first() @@ -204,6 +363,11 @@ def get(self, request, muid=None): class ShareUserProfileAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Profile'], + description="Update Share User Profile.", + responses={200: profile_serializer.ShareUserProfileUpdateSerializer}, + ) def put(self, request): user_id = JWTUtils.fetch_user_id(request) user_settings = UserSettings.objects.filter(user_id=user_id).first() @@ -234,6 +398,9 @@ def put(self, request): # function for generating profile qr code + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve Share User Profile.", + responses={200: profile_serializer.ShareUserProfileUpdateSerializer}, + ) def get(self, request, uuid=None): fs = FileSystemStorage() if uuid is not None: @@ -310,6 +477,11 @@ def get(self, request, uuid=None): class UserLevelsAPI(APIView): + @extend_schema( + tags=['Dashboard - Profile'], + description="Retrieve User Levels.", + responses={200: profile_serializer.UserLevelSerializer}, + ) def get(self, request, muid=None): if muid is not None: user = User.objects.filter(muid=muid).first() @@ -340,6 +512,11 @@ def get(self, request, muid=None): class UserRankAPI(APIView): + @extend_schema( + tags=['Dashboard - Profile'], + description="Retrieve User Rank.", + responses={200: profile_serializer.UserRankSerializer}, + ) def get(self, request, muid): user = User.objects.filter(muid=muid).first() @@ -355,6 +532,9 @@ def get(self, request, muid): class GetSocialsAPI(APIView): + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve Get Socials.", + responses={200: profile_serializer.LinkSocials}, + ) def get(self, request, muid=None): if muid is not None: user = User.objects.filter(muid=muid).first() @@ -385,6 +565,9 @@ def get(self, request, muid=None): class SocialsAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Profile'], description="Update Socials.", + responses={200: OpenApiResponse(description="Success")}, + ) def put(self, request): user_id = JWTUtils.fetch_user_id(request) social_instance = Socials.objects.filter(user_id=user_id).first() @@ -406,6 +589,9 @@ def put(self, request): class ResetPasswordAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Profile'], description="Create Reset Password.", + responses={200: OpenApiResponse(description="Success")}, + ) def post(self, request): user_muid = JWTUtils.fetch_muid(request) user = User.objects.filter(muid=user_muid).first() @@ -437,6 +623,9 @@ def post(self, request): class QrcodeRetrieveAPI(APIView): + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve Qrcode Retrieve.", + responses={200: profile_serializer.UserShareQrcode}, + ) def get(self, request, uuid): try: user = User.objects.prefetch_related().get( @@ -463,6 +652,12 @@ def get(self, request, uuid): class BadgesAPI(APIView): + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve Badges.", + responses={200: inline_serializer("ProfileBadgesResponse", fields={ + "full_name": s.CharField(), + "completed_tasks": s.ListField(child=s.CharField()), + })}, + ) def get(self, request, muid): try: user = User.objects.get(muid=muid) @@ -481,6 +676,11 @@ def get(self, request, muid): class UsertermAPI(APIView): + @extend_schema( + tags=['Dashboard - Profile'], + description="Create Userterm.", + responses={200: UserTermSerializer}, + ) def post(self, request, muid): try: user = User.objects.get(muid=muid) @@ -513,6 +713,9 @@ def post(self, request, muid): return CustomResponse(response=response_data).get_failure_response() return CustomResponse(response="Invalid data provided").get_failure_response() + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve Userterm.", + responses={200: UserTermSerializer}, + ) def get(self, request, muid): try: user = User.objects.get(muid=muid) @@ -536,12 +739,20 @@ def get(self, request, muid): return CustomResponse(response=response_data).get_failure_response() -from datetime import datetime, timedelta -from django.db.models import Sum -from rest_framework.views import APIView - - class KarmaFeedAPI(APIView): + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve Karma Feed.", + responses={200: inline_serializer("ProfileKarmaFeedResponse", fields={ + "top_user": inline_serializer("ProfileKarmaFeedTopUser", fields={ + "karma": s.IntegerField(), + "full_name": s.CharField(allow_null=True), + "muid": s.CharField(allow_null=True), + }), + "top_college": inline_serializer("ProfileKarmaFeedTopCollege", fields={ + "karma": s.IntegerField(), + "name": s.CharField(allow_null=True), + }), + })}, + ) def get(self, request): today = datetime.now().date() @@ -623,6 +834,9 @@ def get(self, request): class UserLevelFeedAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve User Level Feed.", + responses={200: profile_serializer.UserLevelSerializer}, + ) def get(self, request): if not JWTUtils.is_jwt_authenticated(request): return CustomResponse(general_message="Unauthorized").get_failure_response() @@ -634,6 +848,17 @@ def get(self, request): .order_by("-created_at") .first() ) + if not user_level: + # User has not been assigned a level yet (no UserLvlLink row). + # Return neutral defaults instead of dereferencing None. + return CustomResponse( + response={ + "level_order": 0, + "level_name": "", + "level_karma": 0, + "user_karma": 0, + } + ).get_success_response() user_karma = ( KarmaActivityLog.objects.select_related("task") .filter( @@ -658,6 +883,16 @@ def get(self, request): class UserPreferencesAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Profile'], description="Retrieve User Preferences.", + responses={200: inline_serializer("ProfileUserPreferencesResponse", fields={ + "domains": s.ListField(child=s.CharField()), + "endgoals": s.ListField(child=s.CharField()), + "orgs": s.ListField(child=inline_serializer("ProfileUserPreferencesOrg", fields={ + "id": s.CharField(allow_null=True), + "name": s.CharField(allow_null=True), + })), + })}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) domains = UserDomains.objects.filter(user_id=user_id).values_list( @@ -689,6 +924,11 @@ def get(self, request): } ).get_success_response() + @extend_schema( + tags=['Dashboard - Profile'], + description="Partially update User Preferences.", + responses={200: OpenApiResponse(description="Success")}, + ) def patch(self, request): user_id = JWTUtils.fetch_user_id(request) user_settings = UserSettings.objects.filter(user_id=user_id).first() @@ -713,6 +953,11 @@ def patch(self, request): class UserPermuteAPI(APIView): + @extend_schema( + tags=['Dashboard - Profile'], + description="Retrieve User Permute.", + responses={200: profile_serializer.UserPermuteSerializer}, + ) def get(self, request, muid): user = User.objects.prefetch_related("user_domains", "user_organization_link_user__org").filter(muid=muid).first() if user is None: diff --git a/api/dashboard/profile/urls.py b/api/dashboard/profile/urls.py index 93f3ad3cf..1f478217e 100644 --- a/api/dashboard/profile/urls.py +++ b/api/dashboard/profile/urls.py @@ -24,6 +24,7 @@ path("userterm-approved//", profile_view.UsertermAPI.as_view()), path("karma-feed/", profile_view.KarmaFeedAPI.as_view()), path("user-level-feed/", profile_view.UserLevelFeedAPI.as_view()), + path("cover-pic/", profile_view.UserProfileCoverView.as_view()), path("user-preferences/", profile_view.UserPreferencesAPI.as_view()), path("permute//", profile_view.UserPermuteAPI.as_view()), diff --git a/api/dashboard/projects/projects_serializer.py b/api/dashboard/projects/projects_serializer.py index b5ce05a29..0c866d5ad 100644 --- a/api/dashboard/projects/projects_serializer.py +++ b/api/dashboard/projects/projects_serializer.py @@ -1,78 +1,203 @@ +import json + from rest_framework import serializers -from db.projects import ( - Project, - ProjectImage, - Comment, - Vote +from db.projects import ( + Project, ProjectImage, ProjectLink, ProjectSkillLink, + Comment, Vote, ProjectMember, ) +from db.user import User +from db.skill import Skill + + +# ───── Member ───────────────────────────────────────────── + +class ProjectMemberSerializer(serializers.ModelSerializer): + is_linked = serializers.SerializerMethodField() + user_id = serializers.SerializerMethodField() + muid = serializers.SerializerMethodField() + full_name = serializers.SerializerMethodField() + profile_pic = serializers.SerializerMethodField() + + class Meta: + model = ProjectMember + fields = ["id", "is_linked", "user_id", "muid", "full_name", + "profile_pic", "external_name", "role", "created_at"] + + def get_is_linked(self, obj): + return obj.user_id is not None + + def get_user_id(self, obj): + return obj.user.id if obj.user else None + + def get_muid(self, obj): + return obj.user.muid if obj.user else None + + def get_full_name(self, obj): + return obj.user.full_name if obj.user else obj.external_name + + def get_profile_pic(self, obj): + return obj.user.profile_pic if obj.user else None + + +class AddMemberSerializer(serializers.Serializer): + """Accepts exactly one identity: muid, user_id, or external_name.""" + muid = serializers.CharField(required=False, allow_blank=False) + user_id = serializers.CharField(required=False, allow_blank=False) + external_name = serializers.CharField(required=False, allow_blank=False, max_length=100) + role = serializers.CharField(required=False, allow_blank=True, allow_null=True, max_length=50) + + def validate(self, data): + identifiers = [k for k in ("muid", "user_id", "external_name") if data.get(k)] + if len(identifiers) == 0: + raise serializers.ValidationError("one of muid, user_id, or external_name is required") + if len(identifiers) > 1: + raise serializers.ValidationError("only one of muid, user_id, external_name may be set") + + if data.get("muid"): + user = User.objects.filter(muid=data["muid"]).first() + if not user: + raise serializers.ValidationError("mulearn user not found for given muid") + data["resolved_user"] = user + elif data.get("user_id"): + user = User.objects.filter(id=data["user_id"]).first() + if not user: + raise serializers.ValidationError("mulearn user not found for given user_id") + data["resolved_user"] = user + else: + data["resolved_user"] = None # external + return data + + +# ───── Link & Skill ─────────────────────────────────────── + +class ProjectLinkSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectLink + fields = ["id", "label", "url", "position"] + +class ProjectSkillSerializer(serializers.ModelSerializer): + id = serializers.CharField(source="skill.id") + name = serializers.CharField(source="skill.name") + code = serializers.CharField(source="skill.code") + icon = serializers.CharField(source="skill.icon") + + class Meta: + model = ProjectSkillLink + fields = ["id", "name", "code", "icon"] + + +# ───── Vote / Comment ───────────────────────────────────── class VoteSerializer(serializers.ModelSerializer): - user = serializers.CharField(source='user.full_name') - updated_by = serializers.CharField(source='updated_by.full_name') - created_by = serializers.CharField(source='created_by.full_name') - + user = serializers.CharField(source="user.full_name", read_only=True) + user_id = serializers.CharField(source="user.id", read_only=True) + class Meta: model = Vote - fields = "__all__" + fields = ["id", "vote", "project", "user", "user_id", "created_at", "updated_at"] + read_only_fields = ["created_at", "updated_at"] + class CommentSerializer(serializers.ModelSerializer): - user = serializers.CharField(source='user.full_name') - updated_by = serializers.CharField(source='updated_by.full_name') - created_by = serializers.CharField(source='created_by.full_name') - + user = serializers.CharField(source="user.full_name", read_only=True) + user_id = serializers.CharField(source="user.id", read_only=True) + class Meta: model = Comment - fields = "__all__" - + fields = ["id", "comment", "project", "user", "user_id", "created_at", "updated_at"] + read_only_fields = ["created_at", "updated_at"] + + class ProjectImageSerializer(serializers.ModelSerializer): class Meta: model = ProjectImage - fields = ['image'] - + fields = ["image"] + + +# ───── Project ──────────────────────────────────────────── + class ProjectSerializer(serializers.ModelSerializer): - updated_by = serializers.CharField(source='updated_by.full_name',read_only=True) - created_by = serializers.CharField(source='created_by.full_name',read_only=True) - logo = serializers.ImageField(max_length=None, use_url=True) + updated_by = serializers.CharField(source="updated_by.full_name", read_only=True) + created_by = serializers.CharField(source="created_by.full_name", read_only=True) + created_by_id = serializers.CharField(source="created_by.id", read_only=True) + logo = serializers.ImageField(max_length=None, use_url=True, required=False, allow_null=True) images = ProjectImageSerializer(many=True, read_only=True) + links = ProjectLinkSerializer(many=True, read_only=True) + skills = ProjectSkillSerializer(source="skill_links", many=True, read_only=True) + members = ProjectMemberSerializer(many=True, read_only=True) votes = VoteSerializer(many=True, read_only=True) comments = CommentSerializer(many=True, read_only=True) - contributors = serializers.CharField(max_length=200, required=False) class Meta: model = Project fields = [ - "id", - "title", - "logo", - "images", - "description", - "link", - "contributors", - "created_at", - "updated_at", - "created_by", - "updated_by", - "votes", - "comments" + "id", "title", "description", "status", + "logo", "images", "links", "skills", + "members", "votes", "comments", + "created_by", "created_by_id", "updated_by", + "created_at", "updated_at", ] + class ProjectUpdateSerializer(serializers.ModelSerializer): - logo = serializers.ImageField(max_length=None, use_url=True, required=False) - contributors = serializers.CharField(required=False) - title = serializers.CharField(required=False) + """Accepts create / partial-update payload. Logo + images come as files; + links + skills come as JSON-encoded strings (because the request is + multipart/form-data).""" + title = serializers.CharField(required=False, max_length=50) description = serializers.CharField(required=False) - link = serializers.URLField(required=False) + logo = serializers.ImageField(max_length=None, use_url=True, required=False, allow_null=True) + status = serializers.ChoiceField(choices=Project.STATUS_CHOICES, required=False) + links_json = serializers.CharField(required=False, allow_blank=True, write_only=True) + skill_ids_json = serializers.CharField(required=False, allow_blank=True, write_only=True) class Meta: model = Project - fields = [ - "title", - "logo", - "images", - "description", - "link", - "contributors" - ] - + fields = ["title", "description", "status", "logo", "links_json", "skill_ids_json"] + + def validate_links_json(self, value): + if not value: + return [] + try: + data = json.loads(value) + except json.JSONDecodeError: + raise serializers.ValidationError("links_json must be valid JSON") + if not isinstance(data, list): + raise serializers.ValidationError("links_json must be a JSON array") + for item in data: + if not isinstance(item, dict) or "label" not in item or "url" not in item: + raise serializers.ValidationError("each link must have label and url") + if not item["label"].strip(): + raise serializers.ValidationError("link.label cannot be empty") + if not item["url"].strip(): + raise serializers.ValidationError("link.url cannot be empty") + return data + + def validate_skill_ids_json(self, value): + if not value: + return [] + try: + data = json.loads(value) + except json.JSONDecodeError: + raise serializers.ValidationError("skill_ids_json must be valid JSON") + if not isinstance(data, list) or not all(isinstance(x, str) for x in data): + raise serializers.ValidationError("skill_ids_json must be a JSON array of skill id strings") + existing = set(Skill.objects.filter(id__in=data).values_list("id", flat=True)) + missing = set(data) - existing + if missing: + raise serializers.ValidationError(f"unknown skill ids: {sorted(missing)}") + return data + + def create(self, validated_data): + # links_json and skill_ids_json are handled in the view after save(); + # strip them here so they never reach Model.objects.create(). + validated_data.pop("links_json", None) + validated_data.pop("skill_ids_json", None) + return super().create(validated_data) + + def update(self, instance, validated_data): + # Same reason — strip the synthetic fields before the ORM update. + validated_data.pop("links_json", None) + validated_data.pop("skill_ids_json", None) + return super().update(instance, validated_data) diff --git a/api/dashboard/projects/projects_view.py b/api/dashboard/projects/projects_view.py index 5b65a8052..ec332dc35 100644 --- a/api/dashboard/projects/projects_view.py +++ b/api/dashboard/projects/projects_view.py @@ -1,3 +1,4 @@ +from django.db.models import Q from rest_framework.views import APIView from utils.utils import CommonUtils from utils.response import CustomResponse @@ -5,20 +6,31 @@ from db.projects import ( Project, ProjectImage, + ProjectLink, + ProjectSkillLink, Comment, - Vote + Vote, + ProjectMember, ) from db.user import User +from drf_spectacular.utils import extend_schema from .projects_serializer import ( ProjectSerializer, ProjectUpdateSerializer, CommentSerializer, - VoteSerializer + VoteSerializer, + AddMemberSerializer, + ProjectMemberSerializer, ) class ProjectDetailAPIView(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Projects'], + description="Retrieve Project Detail.", + responses={200: ProjectSerializer}, + ) def get(self, request, pk=None): if pk is not None: project = Project.objects.get(id=pk) @@ -31,6 +43,11 @@ def get(self, request, pk=None): general_message="no Project id provided" ).get_failure_response() + @extend_schema( + tags=['Dashboard - Projects'], + description="Update Project Detail.", + responses={200: ProjectSerializer}, + ) def put(self, request, pk=None): user = User.objects.get(id=JWTUtils.fetch_user_id(request)) if pk is None: @@ -46,12 +63,21 @@ def put(self, request, pk=None): ProjectImage.objects.filter(project=project).delete() for image_data in images_data: ProjectImage.objects.create(project=project, image=image_data) + links = serializer.validated_data.get("links_json") + skill_ids = serializer.validated_data.get("skill_ids_json") + if links is not None: + _replace_links(project, links) + if skill_ids is not None: + _replace_skills(project, skill_ids) read_serializer = ProjectSerializer(serializer.instance) return CustomResponse( response={"Project": read_serializer.data} ).get_success_response() return CustomResponse(general_message=serializer.errors).get_failure_response() + @extend_schema(tags=['Dashboard - Projects'], description="Delete Project Detail.", + responses={200: ProjectSerializer}, + ) def delete(self, request, pk=None): if pk is not None: try: @@ -70,23 +96,101 @@ def delete(self, request, pk=None): ).get_failure_response() +def _replace_links(project, links): + ProjectLink.objects.filter(project=project).delete() + for i, item in enumerate(links): + ProjectLink.objects.create( + project=project, + label=item["label"].strip(), + url=item["url"].strip(), + position=item.get("position", i), + ) + + +def _replace_skills(project, skill_ids): + ProjectSkillLink.objects.filter(project=project).delete() + for sid in skill_ids: + ProjectSkillLink.objects.create(project=project, skill_id=sid) + + +class ProjectStatusAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Projects'], + description="Partially update Project Status.", + responses={200: ProjectSerializer}, + ) + def patch(self, request, pk): + user = User.objects.get(id=JWTUtils.fetch_user_id(request)) + try: + project = Project.objects.get(id=pk) + except Project.DoesNotExist: + return CustomResponse(general_message="project not found").get_failure_response() + if project.created_by_id != user.id: + return CustomResponse(general_message="only owner can change status").get_failure_response() + new_status = request.data.get("status") + if new_status not in dict(Project.STATUS_CHOICES): + return CustomResponse(general_message="invalid status").get_failure_response() + project.status = new_status + project.updated_by = user + project.save() + return CustomResponse(response={"Project": ProjectSerializer(project).data}).get_success_response() + + class ProjectsAPIView(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Projects'], + description="Retrieve Projects.", + responses={200: ProjectSerializer}, + ) def get(self, request): - projects = Project.objects.all() - paginated_projects = CommonUtils.get_paginated_queryset( - queryset=projects, - request=request, - search_fields=[], - sort_fields={'created_at': 'created_at'}, + viewer_id = JWTUtils.fetch_user_id(request) + + qs = (Project.objects.all() + .select_related("created_by", "updated_by") + .prefetch_related("images", "links", "skill_links__skill", + "members__user", "votes", "comments")) + + muid = request.query_params.get("muid") + created_by = request.query_params.get("created_by") + status = request.query_params.get("status") + + owner_id_for_visibility = None + if muid: + qs = qs.filter(Q(created_by__muid=muid) | Q(members__user__muid=muid)).distinct() + owner_user = User.objects.filter(muid=muid).only("id").first() + owner_id_for_visibility = owner_user.id if owner_user else None + elif created_by: + qs = qs.filter(created_by_id=created_by) + owner_id_for_visibility = created_by + + if status: + qs = qs.filter(status=status) + else: + if owner_id_for_visibility and owner_id_for_visibility != viewer_id: + qs = qs.filter(status="published") + elif not owner_id_for_visibility: + qs = qs.filter(status="published") + + paginated = CommonUtils.get_paginated_queryset( + queryset=qs, request=request, + search_fields=["title", "description"], + sort_fields={"created_at": "created_at"}, is_pagination=True, ) - serializer = ProjectSerializer(paginated_projects['queryset'], many=True) return CustomResponse().paginated_response( - data={"Projects": serializer.data}, - pagination=paginated_projects['pagination'] + data={"Projects": ProjectSerializer(paginated["queryset"], many=True).data}, + pagination=paginated["pagination"], ) + @extend_schema( + tags=['Dashboard - Projects'], + description="Create Projects.", + request=ProjectUpdateSerializer, + responses={200: ProjectSerializer}, + ) def post(self, request): user = User.objects.get(id=JWTUtils.fetch_user_id(request)) images_data = request.FILES.getlist('images') @@ -95,11 +199,14 @@ def post(self, request): del data['images'] serializer = ProjectUpdateSerializer(data=data) if serializer.is_valid(): + links = serializer.validated_data.get("links_json", []) + skill_ids = serializer.validated_data.get("skill_ids_json", []) project = serializer.save(created_by=user, updated_by=user) if images_data: for image_data in images_data: ProjectImage.objects.create(project=project, image=image_data) - + _replace_links(project, links) + _replace_skills(project, skill_ids) read_serializer = ProjectSerializer(project) return CustomResponse( response={"Project": read_serializer.data} @@ -109,71 +216,149 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() +class ProjectMemberAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Projects'], + description="Retrieve Project Member.", + responses={200: ProjectMemberSerializer}, + ) + def get(self, request, project_id): + members = (ProjectMember.objects.filter(project_id=project_id) + .select_related("user")) + return CustomResponse( + response={"Members": ProjectMemberSerializer(members, many=True).data} + ).get_success_response() + + @extend_schema( + tags=['Dashboard - Projects'], + description="Create Project Member.", + request=AddMemberSerializer, + responses={200: ProjectMemberSerializer}, + ) + def post(self, request, project_id): + user = User.objects.get(id=JWTUtils.fetch_user_id(request)) + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return CustomResponse(general_message="project not found").get_failure_response() + if project.created_by_id != user.id: + return CustomResponse(general_message="only the project owner can add members").get_failure_response() + + serializer = AddMemberSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse(general_message=serializer.errors).get_failure_response() + + resolved_user = serializer.validated_data["resolved_user"] + external_name = serializer.validated_data.get("external_name") or None + role = serializer.validated_data.get("role") or None + + if resolved_user and ProjectMember.objects.filter(project=project, user=resolved_user).exists(): + return CustomResponse(general_message="user is already a member").get_failure_response() + + member = ProjectMember.objects.create( + project=project, + user=resolved_user, + external_name=external_name if not resolved_user else None, + role=role, + created_by=user, + ) + return CustomResponse( + response={"Member": ProjectMemberSerializer(member).data} + ).get_success_response() + + @extend_schema(tags=['Dashboard - Projects'], description="Delete Project Member.", + responses={200: ProjectMemberSerializer}, + ) + def delete(self, request, project_id, pk): + user = User.objects.get(id=JWTUtils.fetch_user_id(request)) + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return CustomResponse(general_message="project not found").get_failure_response() + if project.created_by_id != user.id: + return CustomResponse(general_message="only the project owner can remove members").get_failure_response() + try: + member = ProjectMember.objects.get(id=pk, project_id=project_id) + except ProjectMember.DoesNotExist: + return CustomResponse(general_message="member not found").get_failure_response() + member.delete() + return CustomResponse(general_message="Member removed").get_success_response() + + class ProjectVoteAPI(APIView): - + authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Projects'], + description="Create Project Vote.", + responses={200: VoteSerializer}, + ) def post(self, request): - serializer = VoteSerializer(data=request.data) - if serializer.is_valid(): - serializer.save() - return CustomResponse( - response={"Vote": serializer.data} - ).get_success_response() - return CustomResponse(general_message=serializer.errors).get_failure_response() - - def put(self, request,pk): - vote = Vote.objects.get(id=pk) - serializer = VoteSerializer(vote, data=request.data) - if serializer.is_valid(): - serializer.save() - return CustomResponse( - response={"Vote": serializer.data} - ).get_success_response() - return CustomResponse(message=serializer.errors).get_failure_response() - - def delete(self, request,pk): + user = User.objects.get(id=JWTUtils.fetch_user_id(request)) + project_id = request.data.get("project") + vote_value = request.data.get("vote") + if vote_value not in ("upvote", "downvote"): + return CustomResponse(general_message="invalid vote value").get_failure_response() + vote, _ = Vote.objects.update_or_create( + project_id=project_id, user=user, + defaults={"vote": vote_value, "created_by": user, "updated_by": user}, + ) + return CustomResponse(response={"Vote": VoteSerializer(vote).data}).get_success_response() + + @extend_schema(tags=['Dashboard - Projects'], description="Delete Project Vote.", + responses={200: VoteSerializer}, + ) + def delete(self, request, pk): + user = User.objects.get(id=JWTUtils.fetch_user_id(request)) try: - vote = Vote.objects.get(id=pk) - vote.delete() - return CustomResponse( - general_message="Vote deleted successfully" - ).get_success_response() + Vote.objects.get(id=pk, user=user).delete() except Vote.DoesNotExist: - return CustomResponse( - general_message="vote not found" - ).get_failure_response() + return CustomResponse(general_message="vote not found").get_failure_response() + return CustomResponse(general_message="Vote deleted successfully").get_success_response() class ProjectCommentAPI(APIView): - - + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Projects'], + description="Create Project Comment.", + request=CommentSerializer, + responses={200: CommentSerializer}, + ) def post(self, request): - serializer = CommentSerializer(data=request.data) + user = User.objects.get(id=JWTUtils.fetch_user_id(request)) + data = {"comment": request.data.get("comment"), "project": request.data.get("project")} + serializer = CommentSerializer(data=data) if serializer.is_valid(): - serializer.save() - return CustomResponse( - response={"Comment": serializer.data} - ).get_success_response() + comment = serializer.save(user=user, created_by=user, updated_by=user) + return CustomResponse(response={"Comment": CommentSerializer(comment).data}).get_success_response() return CustomResponse(general_message=serializer.errors).get_failure_response() - def put(self, request,pk): - comment = Comment.objects.get(id=pk) - serializer = CommentSerializer(comment, data=request.data) + @extend_schema(tags=['Dashboard - Projects'], description="Update Project Comment.", + responses={200: CommentSerializer}, + ) + def put(self, request, pk): + user = User.objects.get(id=JWTUtils.fetch_user_id(request)) + try: + comment = Comment.objects.get(id=pk, user=user) + except Comment.DoesNotExist: + return CustomResponse(general_message="comment not found or not owned").get_failure_response() + serializer = CommentSerializer(comment, data=request.data, partial=True) if serializer.is_valid(): - serializer.save() - return CustomResponse( - response={"Comment": serializer.data} - ).get_success_response() - return CustomResponse(message=serializer.errors).get_failure_response() + serializer.save(updated_by=user) + return CustomResponse(response={"Comment": serializer.data}).get_success_response() + return CustomResponse(general_message=serializer.errors).get_failure_response() + @extend_schema(tags=['Dashboard - Projects'], description="Delete Project Comment.", + responses={200: CommentSerializer}, + ) def delete(self, request, pk): + user = User.objects.get(id=JWTUtils.fetch_user_id(request)) try: - comment = Comment.objects.get(id=pk) - comment.delete() - return CustomResponse( - general_message="comment deleted successfully" - ).get_success_response() + Comment.objects.get(id=pk, user=user).delete() except Comment.DoesNotExist: - return CustomResponse( - general_message="comment not found" - ).get_failure_response() \ No newline at end of file + return CustomResponse(general_message="comment not found or not owned").get_failure_response() + return CustomResponse(general_message="comment deleted successfully").get_success_response() \ No newline at end of file diff --git a/api/dashboard/projects/tests/__init__.py b/api/dashboard/projects/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/dashboard/projects/tests/conftest.py b/api/dashboard/projects/tests/conftest.py new file mode 100644 index 000000000..75030bbde --- /dev/null +++ b/api/dashboard/projects/tests/conftest.py @@ -0,0 +1,43 @@ +import pytest +from rest_framework.test import APIClient +from db.user import User +from db.projects import Project + + +@pytest.fixture +def user_fixture(db): + return User.objects.create(id="u-owner-1", muid="MU-OWNER", full_name="Owner User", email="owner@test.com") + + +@pytest.fixture +def other_user_fixture(db): + return User.objects.create(id="u-other-1", muid="MU-OTHER", full_name="Other User", email="other@test.com") + + +@pytest.fixture +def auth_client(user_fixture): + client = APIClient() + client.force_authenticate(user=user_fixture) + return client + + +@pytest.fixture +def other_auth_client(other_user_fixture): + client = APIClient() + client.force_authenticate(user=other_user_fixture) + return client + + +@pytest.fixture +def project_fixture(user_fixture): + return Project.objects.create( + id="p-1", title="Demo", description="d", + status="published", created_by=user_fixture, updated_by=user_fixture, + ) + + +@pytest.fixture +def skill_fixture(user_fixture): + from db.skill import Skill + return Skill.objects.create(id="sk-1", name="Python", code="PY", + created_by=user_fixture, updated_by=user_fixture) diff --git a/api/dashboard/projects/tests/test_members.py b/api/dashboard/projects/tests/test_members.py new file mode 100644 index 000000000..e1c9c7372 --- /dev/null +++ b/api/dashboard/projects/tests/test_members.py @@ -0,0 +1,94 @@ +import pytest +from rest_framework.test import APIClient + + +URL = lambda pid: f"/api/v1/dashboard/projects/{pid}/members/" +ITEM_URL = lambda pid, mid: f"/api/v1/dashboard/projects/{pid}/members/{mid}/" + + +@pytest.mark.django_db +def test_add_member_requires_auth(project_fixture, other_user_fixture): + resp = APIClient().post(URL(project_fixture.id), + {"muid": other_user_fixture.muid}, format="json") + assert resp.status_code in (401, 403) + + +@pytest.mark.django_db +def test_owner_adds_linked_member_by_muid(project_fixture, other_user_fixture, auth_client): + resp = auth_client.post(URL(project_fixture.id), + {"muid": other_user_fixture.muid, "role": "lead"}, format="json") + assert resp.status_code == 200, resp.json() + body = resp.json()["response"]["Member"] + assert body["is_linked"] is True + assert body["user_id"] == other_user_fixture.id + assert body["muid"] == other_user_fixture.muid + assert body["full_name"] == other_user_fixture.full_name + assert body["external_name"] is None + assert body["role"] == "lead" + + +@pytest.mark.django_db +def test_owner_adds_external_member(project_fixture, auth_client): + resp = auth_client.post(URL(project_fixture.id), + {"external_name": "Jane Pixel", "role": "designer"}, format="json") + assert resp.status_code == 200, resp.json() + body = resp.json()["response"]["Member"] + assert body["is_linked"] is False + assert body["user_id"] is None + assert body["external_name"] == "Jane Pixel" + assert body["full_name"] == "Jane Pixel" + + +@pytest.mark.django_db +def test_neither_identity_returns_400(project_fixture, auth_client): + resp = auth_client.post(URL(project_fixture.id), {"role": "lead"}, format="json") + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_both_identities_returns_400(project_fixture, other_user_fixture, auth_client): + resp = auth_client.post(URL(project_fixture.id), + {"muid": other_user_fixture.muid, "external_name": "X"}, format="json") + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_non_owner_cannot_add_member(project_fixture, other_user_fixture, other_auth_client): + resp = other_auth_client.post(URL(project_fixture.id), + {"muid": other_user_fixture.muid}, format="json") + assert resp.status_code in (400, 403) + + +@pytest.mark.django_db +def test_duplicate_linked_member_returns_400(project_fixture, other_user_fixture, auth_client): + auth_client.post(URL(project_fixture.id), {"muid": other_user_fixture.muid}, format="json") + second = auth_client.post(URL(project_fixture.id), {"muid": other_user_fixture.muid}, format="json") + assert second.status_code == 400 + + +@pytest.mark.django_db +def test_duplicate_external_name_allowed(project_fixture, auth_client): + first = auth_client.post(URL(project_fixture.id), {"external_name": "Jane"}, format="json") + second = auth_client.post(URL(project_fixture.id), {"external_name": "Jane"}, format="json") + assert first.status_code == 200 and second.status_code == 200 + assert first.json()["response"]["Member"]["id"] != second.json()["response"]["Member"]["id"] + + +@pytest.mark.django_db +def test_owner_removes_member(project_fixture, other_user_fixture, auth_client): + create = auth_client.post(URL(project_fixture.id), {"muid": other_user_fixture.muid}, format="json") + member_id = create.json()["response"]["Member"]["id"] + delete = auth_client.delete(ITEM_URL(project_fixture.id, member_id)) + assert delete.status_code == 200 + + +@pytest.mark.django_db +def test_list_members_includes_linked_and_external(project_fixture, other_user_fixture, auth_client): + auth_client.post(URL(project_fixture.id), {"muid": other_user_fixture.muid}, format="json") + auth_client.post(URL(project_fixture.id), {"external_name": "Jane"}, format="json") + resp = auth_client.get(URL(project_fixture.id)) + assert resp.status_code == 200 + members = resp.json()["response"]["Members"] + assert len(members) == 2 + is_linked = {m["is_linked"] for m in members} + assert is_linked == {True, False} diff --git a/api/dashboard/projects/tests/test_projects.py b/api/dashboard/projects/tests/test_projects.py new file mode 100644 index 000000000..152e04229 --- /dev/null +++ b/api/dashboard/projects/tests/test_projects.py @@ -0,0 +1,48 @@ +import pytest +from rest_framework.test import APIClient +from db.projects import Project + + +@pytest.fixture +def draft_project(user_fixture): + return Project.objects.create(id="p-draft", title="Draft", description="d", + status="draft", created_by=user_fixture, updated_by=user_fixture) + + +@pytest.fixture +def other_project(other_user_fixture): + return Project.objects.create(id="p-other", title="Other", description="o", + status="published", created_by=other_user_fixture, updated_by=other_user_fixture) + + +@pytest.mark.django_db +def test_list_by_muid_owner_sees_all_statuses(auth_client, user_fixture, project_fixture, draft_project): + resp = auth_client.get(f"/api/v1/dashboard/projects/?muid={user_fixture.muid}") + ids = [p["id"] for p in resp.json()["response"]["Projects"]] + assert project_fixture.id in ids + assert draft_project.id in ids + + +@pytest.mark.django_db +def test_list_by_muid_public_viewer_only_sees_published(other_auth_client, user_fixture, project_fixture, draft_project): + resp = other_auth_client.get(f"/api/v1/dashboard/projects/?muid={user_fixture.muid}") + ids = [p["id"] for p in resp.json()["response"]["Projects"]] + assert project_fixture.id in ids + assert draft_project.id not in ids + + +@pytest.mark.django_db +def test_list_by_muid_includes_member_of_projects(auth_client, other_auth_client, other_user_fixture, project_fixture): + auth_client.post(f"/api/v1/dashboard/projects/{project_fixture.id}/members/", + {"muid": other_user_fixture.muid}, format="json") + resp = other_auth_client.get(f"/api/v1/dashboard/projects/?muid={other_user_fixture.muid}") + ids = [p["id"] for p in resp.json()["response"]["Projects"]] + assert project_fixture.id in ids + + +@pytest.mark.django_db +def test_status_filter_explicit(auth_client, user_fixture, project_fixture, draft_project): + resp = auth_client.get(f"/api/v1/dashboard/projects/?muid={user_fixture.muid}&status=draft") + ids = [p["id"] for p in resp.json()["response"]["Projects"]] + assert draft_project.id in ids + assert project_fixture.id not in ids diff --git a/api/dashboard/projects/tests/test_status.py b/api/dashboard/projects/tests/test_status.py new file mode 100644 index 000000000..6a07b7c76 --- /dev/null +++ b/api/dashboard/projects/tests/test_status.py @@ -0,0 +1,40 @@ +import pytest + + +@pytest.mark.django_db +def test_owner_can_publish_draft(auth_client, project_fixture): + project_fixture.status = "draft" + project_fixture.save() + resp = auth_client.patch(f"/api/v1/dashboard/projects/{project_fixture.id}/status/", + {"status": "published"}, format="json") + assert resp.status_code == 200 + assert resp.json()["response"]["Project"]["status"] == "published" + + +@pytest.mark.django_db +def test_non_owner_cannot_change_status(other_auth_client, project_fixture): + resp = other_auth_client.patch(f"/api/v1/dashboard/projects/{project_fixture.id}/status/", + {"status": "archived"}, format="json") + assert resp.status_code in (400, 403) + + +@pytest.mark.django_db +def test_invalid_status_rejected(auth_client, project_fixture): + resp = auth_client.patch(f"/api/v1/dashboard/projects/{project_fixture.id}/status/", + {"status": "deleted"}, format="json") + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_create_project_persists_links_and_skills(auth_client, skill_fixture): + import json + resp = auth_client.post("/api/v1/dashboard/projects/", { + "title": "X", "description": "y", "status": "published", + "links_json": json.dumps([{"label": "GitHub", "url": "https://github.com/x/y"}, + {"label": "Demo", "url": "https://demo.example"}]), + "skill_ids_json": json.dumps([skill_fixture.id]), + }, format="multipart") + assert resp.status_code == 200, resp.json() + body = resp.json()["response"]["Project"] + assert len(body["links"]) == 2 + assert body["skills"][0]["id"] == skill_fixture.id diff --git a/api/dashboard/projects/tests/test_votes_comments.py b/api/dashboard/projects/tests/test_votes_comments.py new file mode 100644 index 000000000..fd482c4f0 --- /dev/null +++ b/api/dashboard/projects/tests/test_votes_comments.py @@ -0,0 +1,46 @@ +import pytest +from rest_framework.test import APIClient + + +@pytest.mark.django_db +def test_vote_requires_authentication(project_fixture): + client = APIClient() + resp = client.post("/api/v1/dashboard/projects/vote/", + {"vote": "upvote", "project": project_fixture.id}, format="json") + assert resp.status_code in (401, 403), resp.json() + + +@pytest.mark.django_db +def test_vote_records_user(project_fixture, auth_client, user_fixture): + resp = auth_client.post("/api/v1/dashboard/projects/vote/", + {"vote": "upvote", "project": project_fixture.id}, format="json") + assert resp.status_code == 200, resp.json() + body = resp.json()["response"]["Vote"] + assert body["user_id"] == user_fixture.id + assert body["vote"] == "upvote" + + +@pytest.mark.django_db +def test_vote_is_unique_per_user_project(project_fixture, auth_client): + auth_client.post("/api/v1/dashboard/projects/vote/", + {"vote": "upvote", "project": project_fixture.id}, format="json") + second = auth_client.post("/api/v1/dashboard/projects/vote/", + {"vote": "downvote", "project": project_fixture.id}, format="json") + assert second.status_code == 200 + assert second.json()["response"]["Vote"]["vote"] == "downvote" + + +@pytest.mark.django_db +def test_comment_requires_authentication(project_fixture): + client = APIClient() + resp = client.post("/api/v1/dashboard/projects/comment/", + {"comment": "hi", "project": project_fixture.id}, format="json") + assert resp.status_code in (401, 403) + + +@pytest.mark.django_db +def test_comment_records_user(project_fixture, auth_client, user_fixture): + resp = auth_client.post("/api/v1/dashboard/projects/comment/", + {"comment": "great", "project": project_fixture.id}, format="json") + assert resp.status_code == 200 + assert resp.json()["response"]["Comment"]["user_id"] == user_fixture.id diff --git a/api/dashboard/projects/urls.py b/api/dashboard/projects/urls.py index eee7f4334..1db795354 100644 --- a/api/dashboard/projects/urls.py +++ b/api/dashboard/projects/urls.py @@ -3,10 +3,13 @@ from . import projects_view urlpatterns = [ - path('', projects_view.ProjectsAPIView.as_view()), - path('/', projects_view.ProjectDetailAPIView.as_view()), - path('vote/', projects_view.ProjectVoteAPI.as_view()), - path('vote//', projects_view.ProjectVoteAPI.as_view()), - path('comment/', projects_view.ProjectCommentAPI.as_view()), - path('comment//', projects_view.ProjectCommentAPI.as_view()), -] \ No newline at end of file + path("", projects_view.ProjectsAPIView.as_view()), + path("/", projects_view.ProjectDetailAPIView.as_view()), + path("/status/", projects_view.ProjectStatusAPI.as_view()), + path("/members/", projects_view.ProjectMemberAPI.as_view()), + path("/members//", projects_view.ProjectMemberAPI.as_view()), + path("vote/", projects_view.ProjectVoteAPI.as_view()), + path("vote//", projects_view.ProjectVoteAPI.as_view()), + path("comment/", projects_view.ProjectCommentAPI.as_view()), + path("comment//", projects_view.ProjectCommentAPI.as_view()), +] diff --git a/api/dashboard/referral/referral_view.py b/api/dashboard/referral/referral_view.py index dfa19dcaa..4662cd4a5 100644 --- a/api/dashboard/referral/referral_view.py +++ b/api/dashboard/referral/referral_view.py @@ -11,6 +11,7 @@ from utils.utils import DateTimeUtils from utils.utils import send_template_mail from .referral_serializer import ReferralListSerializer +from drf_spectacular.utils import extend_schema FROM_MAIL = config('FROM_MAIL') @@ -18,6 +19,9 @@ class Referral(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Referral'], description="Create Referral.", + responses={200: ReferralListSerializer}, + ) def post(self, request): receiver_email = request.data.get("email") invite_type = request.data.get('invite_type') @@ -68,6 +72,11 @@ def post(self, request): class ReferralListAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Referral'], + description="Retrieve Referral List.", + responses={200: ReferralListSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_referral_link = UserReferralLink.objects.filter( diff --git a/api/dashboard/roles/dash_roles_serializer.py b/api/dashboard/roles/dash_roles_serializer.py index e1dea7697..62b0da324 100644 --- a/api/dashboard/roles/dash_roles_serializer.py +++ b/api/dashboard/roles/dash_roles_serializer.py @@ -2,10 +2,10 @@ from rest_framework import serializers -from db.user import Role, User, UserRoleLink +from db.user import Role, User, UserRoleLink, UserMentor from utils.permission import JWTUtils from utils.utils import DateTimeUtils, DiscordWebhooks -from utils.types import WebHookActions, WebHookCategory +from utils.types import WebHookActions, WebHookCategory, RoleType, InternGuild from django.db.models import Q from django.db import transaction @@ -112,26 +112,340 @@ class Meta: class UserRoleCreateSerializer(serializers.ModelSerializer): user_id = serializers.CharField(required=True, source="user.id") role_id = serializers.CharField(required=True, source="role.id") + # Required when assigning the Intern role. + guild = serializers.ChoiceField( + choices=InternGuild.get_all_values(), + required=False, + allow_null=True, + default=None, + ) + # Required when assigning the Mentor role — determines the mentor tier. + mentor_tier = serializers.ChoiceField( + choices=UserMentor.MentorTier.choices, + required=False, + allow_null=True, + default=None, + ) + # Required for IG_MENTOR tier — list of Interest Group UUIDs. + ig_ids = serializers.ListField( + child=serializers.CharField(), + required=False, + allow_empty=True, + default=list, + ) + # Required for CAMPUS_MENTOR / COMPANY_MENTOR tiers. + org_id = serializers.CharField(required=False, allow_null=True, default=None) + # Required when assigning the Company role — name and description for the company profile. + company_name = serializers.CharField(required=False, allow_null=True, default=None) + company_description = serializers.CharField(required=False, allow_null=True, default=None) class Meta: model = UserRoleLink - fields = ["user_id", "role_id"] + fields = ["user_id", "role_id", "guild", "mentor_tier", "ig_ids", "org_id", + "company_name", "company_description"] + + def validate(self, data): + from db.task import InterestGroup + from db.organization import Organization + from utils.types import OrganizationType + + guild = data.get("guild") + mentor_tier = data.get("mentor_tier") + ig_ids = data.get("ig_ids") or [] + org_id = data.get("org_id") + role_id = data.get("role", {}).get("id") if isinstance(data.get("role"), dict) else None + + # Look up the actual role object for title check + role_obj = Role.objects.filter(id=role_id).first() if role_id else None + is_mentor_role = role_obj and role_obj.title == RoleType.MENTOR.value + is_intern_role = role_obj and role_obj.title == RoleType.INTERN.value + is_company_role = role_obj and role_obj.title == RoleType.COMPANY.value + + errors = {} + + # ── Mentor validation ─────────────────────────────────────────────── + if is_mentor_role: + if not mentor_tier: + errors["mentor_tier"] = "mentor_tier is required when assigning the Mentor role." + else: + if mentor_tier == UserMentor.MentorTier.IG_MENTOR: + if not ig_ids: + errors["ig_ids"] = "ig_ids is required for IG_MENTOR tier." + else: + invalid_igs = [ + ig_id for ig_id in ig_ids + if not InterestGroup.objects.filter(id=ig_id).exists() + ] + if invalid_igs: + errors["ig_ids"] = f"Invalid IG IDs: {', '.join(invalid_igs)}" + + elif mentor_tier in ( + UserMentor.MentorTier.CAMPUS_MENTOR, + UserMentor.MentorTier.COMPANY_MENTOR, + ): + if not org_id: + errors["org_id"] = f"org_id is required for {mentor_tier}." + else: + expected_type = ( + OrganizationType.COLLEGE.value + if mentor_tier == UserMentor.MentorTier.CAMPUS_MENTOR + else OrganizationType.COMPANY.value + ) + org = Organization.objects.filter( + id=org_id, org_type=expected_type + ).first() + if org is None: + errors["org_id"] = ( + f"org_id must reference a valid {expected_type} organisation." + ) + else: + data["_org"] = org + + # ── Intern validation ─────────────────────────────────────────────── + if is_intern_role and not guild: + errors["guild"] = "guild is required when assigning the Intern role." + + # ── Company validation ────────────────────────────────────────────── + if is_company_role: + if not data.get("company_name"): + errors["company_name"] = "company_name is required when assigning the Company role." + if not data.get("company_description"): + errors["company_description"] = "company_description is required when assigning the Company role." + + if errors: + raise serializers.ValidationError(errors) + + data['_is_mentor_role'] = is_mentor_role + data['_is_intern_role'] = is_intern_role + data['_is_company_role'] = is_company_role + return data def create(self, validated_data): - if user_role_link := UserRoleLink.objects.filter( - role_id=validated_data["role"]["id"], user_id=validated_data["user"]["id"] - ).first(): - return user_role_link - - user_id = JWTUtils.fetch_user_id(self.context.get("request")) + guild = validated_data.pop("guild", None) + mentor_tier = validated_data.pop("mentor_tier", None) + ig_ids = validated_data.pop("ig_ids", []) or [] + org = validated_data.pop("_org", None) + org_id = validated_data.pop("org_id", None) + company_name = validated_data.pop("company_name", None) + company_description = validated_data.pop("company_description", None) + is_mentor_role = validated_data.pop("_is_mentor_role", False) + is_intern_role = validated_data.pop("_is_intern_role", False) + is_company_role = validated_data.pop("_is_company_role", False) + admin_user_id = JWTUtils.fetch_user_id(self.context.get("request")) + + target_user_id = validated_data["user"]["id"] + role_id = validated_data["role"]["id"] + now = DateTimeUtils.get_current_utc_time() - validated_data["user_id"] = (validated_data.pop("user"))["id"] - validated_data["role_id"] = (validated_data.pop("role"))["id"] - validated_data["verified"] = True - validated_data["created_by_id"] = user_id - validated_data["created_at"] = DateTimeUtils.get_current_utc_time() - - return super().create(validated_data) + with transaction.atomic(): + # Duplicate check (Mentor role is per-user, not per-IG, going forward) + existing = UserRoleLink.objects.filter( + role_id=role_id, + user_id=target_user_id, + ).first() + + if not existing: + validated_data["user_id"] = (validated_data.pop("user"))["id"] + validated_data["role_id"] = (validated_data.pop("role"))["id"] + validated_data["verified"] = True + validated_data["is_active"] = True + validated_data["created_by_id"] = admin_user_id + validated_data["created_at"] = now + role_link = super().create(validated_data) + else: + role_link = existing + + # --- Mentor-specific provisioning --- + # When the Mentor role is assigned, auto-create/approve UserMentor + # plus all tier-specific linked rows (UserIgLink / UserOrganizationLink). + if is_mentor_role and mentor_tier: + from db.task import UserIgLink, InterestGroup + + # 1. Create / approve UserMentor record + mentor_record, created = UserMentor.objects.get_or_create( + user_id=target_user_id, + mentor_tier=mentor_tier, + org=org, + defaults={ + "status": UserMentor.Status.APPROVED, + "preferred_ig_ids": ig_ids if ig_ids else None, + "verified_by_id": admin_user_id, + "verified_at": now, + "updated_by_id": admin_user_id, + "updated_at": now, + "created_by_id": admin_user_id, + "created_at": now, + }, + ) + if not created and mentor_record.status != UserMentor.Status.APPROVED: + mentor_record.status = UserMentor.Status.APPROVED + mentor_record.verified_by_id = admin_user_id + mentor_record.verified_at = now + mentor_record.updated_by_id = admin_user_id + mentor_record.updated_at = now + mentor_record.save(update_fields=[ + "status", "verified_by_id", "verified_at", + "updated_by_id", "updated_at", + ]) + + # 2. IG_MENTOR: create/activate UserIgLink per ig_id + if mentor_tier == UserMentor.MentorTier.IG_MENTOR and ig_ids: + for ig_id in ig_ids: + ig = InterestGroup.objects.filter(id=ig_id).first() + if ig: + ig_link, _ = UserIgLink.objects.get_or_create( + user_id=target_user_id, + ig=ig, + defaults={ + "assignment_type": UserIgLink.AssignmentType.MENTOR, + "is_active": True, + "assigned_by_id": admin_user_id, + "created_by_id": admin_user_id, + "created_at": now, + }, + ) + if not ig_link.is_active: + ig_link.assignment_type = UserIgLink.AssignmentType.MENTOR + ig_link.is_active = True + ig_link.assigned_by_id = admin_user_id + ig_link.save(update_fields=[ + "assignment_type", "is_active", "assigned_by_id" + ]) + + # 3. CAMPUS/COMPANY_MENTOR: create/verify UserOrganizationLink + elif mentor_tier in ( + UserMentor.MentorTier.CAMPUS_MENTOR, + UserMentor.MentorTier.COMPANY_MENTOR, + ) and org: + from db.organization import UserOrganizationLink + org_link, _ = UserOrganizationLink.objects.get_or_create( + user_id=target_user_id, + org=org, + defaults={ + "verified": True, + "created_by_id": admin_user_id, + "created_at": now, + }, + ) + if not org_link.verified: + org_link.verified = True + org_link.save(update_fields=["verified"]) + + role_link._mentor_profile_created = created + else: + role_link._mentor_profile_created = False + + # --- Company-specific provisioning --- + # When the Company role is assigned, auto-create (or verify) the + # Company profile, an Organization row, and a UserOrganizationLink. + if is_company_role and company_name and company_description: + from db.company import Company + from db.organization import Organization as _Org, UserOrganizationLink as _UOL + from django.utils.text import slugify + from utils.types import OrganizationType + + # 1. Ensure a unique slug for the company + base_slug = slugify(company_name) + slug = base_slug + counter = 1 + while Company.objects.filter(slug=slug).exclude( + company_user_id=target_user_id + ).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + + # 2. Create or update the Company record → immediately verified + company_obj, company_created = Company.objects.get_or_create( + company_user_id=target_user_id, + defaults={ + "name": company_name, + "description": company_description, + "slug": slug, + "status": "verified", + "verified_by": admin_user_id, + "verified_at": now, + "updated_by": admin_user_id, + }, + ) + if not company_created and company_obj.status != "verified": + company_obj.status = "verified" + company_obj.verified_by = admin_user_id + company_obj.verified_at = now + company_obj.updated_by = admin_user_id + company_obj.save(update_fields=[ + "status", "verified_by", "verified_at", "updated_by" + ]) + + # 3. Ensure an Organization row exists for this company + org_obj, _ = _Org.objects.get_or_create( + title=company_obj.name, + org_type=OrganizationType.COMPANY.value, + defaults={ + "code": company_obj.slug[:12], + "created_by_id": admin_user_id, + "updated_by_id": admin_user_id, + "created_at": now, + "updated_at": now, + }, + ) + + # 4. Link the user to the Organization (verified) + org_link, _ = _UOL.objects.get_or_create( + user_id=target_user_id, + org=org_obj, + defaults={ + "verified": True, + "created_by_id": admin_user_id, + "created_at": now, + }, + ) + if not org_link.verified: + org_link.verified = True + org_link.save(update_fields=["verified"]) + + role_link._company_created = company_created + else: + role_link._company_created = False + + # --- Intern-specific provisioning --- + # When the Intern role is assigned, automatically create (or reactivate) + # the UserInternGuildLink row so the intern dashboard is immediately accessible. + if is_intern_role and guild: + from db.intern import UserInternGuildLink + from utils.types import InternGuildStatus + now = DateTimeUtils.get_current_utc_time() + + intern_link = UserInternGuildLink.objects.filter( + user_id=target_user_id + ).first() + + if intern_link is None: + # Fresh onboarding + UserInternGuildLink.objects.create( + user_id=target_user_id, + guild=guild, + status=InternGuildStatus.ACTIVE.value, + created_by_id=admin_user_id, + updated_by_id=admin_user_id, + ) + role_link._intern_guild_created = True + else: + # Reactivate a previously inactive intern or update guild + changed = False + if intern_link.status == InternGuildStatus.INACTIVE.value: + intern_link.status = InternGuildStatus.ACTIVE.value + changed = True + if intern_link.guild != guild: + intern_link.guild = guild + changed = True + if changed: + intern_link.updated_by_id = admin_user_id + intern_link.save(update_fields=["status", "guild", "updated_by_id"]) + role_link._intern_guild_created = False + else: + role_link._intern_guild_created = False + + return role_link class UserRoleBulkAssignSerializer(serializers.ModelSerializer): diff --git a/api/dashboard/roles/dash_roles_views.py b/api/dashboard/roles/dash_roles_views.py index c2dc8291d..ad647c6a0 100644 --- a/api/dashboard/roles/dash_roles_views.py +++ b/api/dashboard/roles/dash_roles_views.py @@ -1,12 +1,12 @@ import uuid -from django.db import IntegrityError +from django.db import IntegrityError, transaction from rest_framework.views import APIView from db.user import Role, User, UserRoleLink from utils.permission import CustomizePermission, role_required, JWTUtils from utils.response import CustomResponse from utils.types import RoleType, WebHookActions, WebHookCategory -from utils.utils import CommonUtils, DiscordWebhooks, ImportCSV +from utils.utils import CommonUtils, DateTimeUtils, DiscordWebhooks, ImportCSV from . import dash_roles_serializer from openpyxl import load_workbook @@ -14,12 +14,18 @@ from io import BytesIO from django.http import FileResponse from django.db.models import Q +from drf_spectacular.utils import extend_schema, OpenApiResponse class RoleAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Retrieve Role.", + responses={200: dash_roles_serializer.RoleDashboardSerializer}, + ) def get(self, request): roles_queryset = Role.objects.all() @@ -53,6 +59,11 @@ def get(self, request): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Partially update Role.", + responses={200: dash_roles_serializer.RoleDashboardSerializer}, + ) def patch(self, request, roles_id): role = Role.objects.get(id=roles_id) old_name = role.title @@ -84,6 +95,9 @@ def patch(self, request, roles_id): ).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Roles'], description="Delete Role.", + responses={200: dash_roles_serializer.RoleDashboardSerializer}, + ) def delete(self, request, roles_id): role = Role.objects.get(id=roles_id) role.delete() @@ -96,6 +110,12 @@ def delete(self, request, roles_id): ).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Create Role.", + request=dash_roles_serializer.RoleDashboardSerializer, + responses={200: dash_roles_serializer.RoleDashboardSerializer}, + ) def post(self, request): serializer = dash_roles_serializer.RoleDashboardSerializer( data=request.data, partial=True, context={"request": request} @@ -121,6 +141,11 @@ class RoleManagementCSV(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Retrieve Role Management C S V.", + responses={200: dash_roles_serializer.RoleDashboardSerializer}, + ) def get(self, request): role = Role.objects.all() @@ -134,6 +159,11 @@ class UserRoleSearchAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Retrieve User Role Search.", + responses={200: dash_roles_serializer.UserRoleSearchSerializer}, + ) def get(self, request, role_id): user = User.objects.filter(user_role_link_user__role_id=role_id).distinct() @@ -166,6 +196,11 @@ class UserRoleLinkManagement(APIView): """ @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Retrieve User Role Link Management.", + responses={200: dash_roles_serializer.UserRoleLinkManagementSerializer}, + ) def get(self, request, role_id): """ Lists all the users with a given role @@ -179,6 +214,11 @@ def get(self, request, role_id): return CustomResponse(response=serialized_users.data).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Update User Role Link Management.", + responses={200: dash_roles_serializer.UserRoleLinkManagementSerializer}, + ) def put(self, request, role_id): """ Lists all the users without a given role; @@ -195,10 +235,33 @@ def put(self, request, role_id): return CustomResponse(response=serialized_users.data).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Create User Role Link Management.", + request=dash_roles_serializer.RoleAssignmentSerializer, + responses={200: dash_roles_serializer.UserRoleLinkManagementSerializer}, + ) def post(self, request, role_id): """ - Assigns a large bunch of users a certain role + Assigns a large bunch of users a certain role. + Blocked for special roles (Mentor, Intern, Company) that require + per-user provisioning — use POST /dashboard/roles/user-role/ instead. """ + # Guard: block bulk-assign for roles that require individual provisioning + _SPECIAL_ROLES = [ + RoleType.MENTOR.value, + RoleType.INTERN.value, + RoleType.COMPANY.value, + ] + role_obj = Role.objects.filter(pk=role_id).first() + if role_obj and role_obj.title in _SPECIAL_ROLES: + return CustomResponse( + general_message=( + f"The '{role_obj.title}' role cannot be bulk-assigned because it requires " + f"additional provisioning. Use POST /dashboard/roles/user-role/ instead." + ) + ).get_failure_response() + request_data = request.data.copy() request_data["role"] = role_id request_data["created_by"] = JWTUtils.fetch_user_id(request) @@ -213,6 +276,9 @@ def post(self, request, role_id): return CustomResponse(response=serialized_users.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Roles'], description="Partially update User Role Link Management.", + responses={200: dash_roles_serializer.UserRoleLinkManagementSerializer}, + ) def patch(self, request, role_id): """ Removes a role from a large bunch of users @@ -265,6 +331,12 @@ class UserRole(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Create User Role.", + request=dash_roles_serializer.UserRoleCreateSerializer, + responses={200: dash_roles_serializer.UserRoleCreateSerializer}, + ) def post(self, request): serializer = dash_roles_serializer.UserRoleCreateSerializer( data=request.data, context={"request": request} @@ -275,34 +347,101 @@ def post(self, request): general_message=serializer.errors ).get_failure_response() - serializer.save() + role_link = serializer.save() DiscordWebhooks.general_updates( WebHookCategory.USER_ROLE.value, WebHookActions.UPDATE.value, request.data.get("user_id"), ) + + response_data = {"message": "Role Added Successfully"} + # If this was a Mentor role assignment, tell the admin whether + # the UserMentor profile was freshly created or already existed. + if hasattr(role_link, '_mentor_profile_created'): + response_data["mentor_profile_created"] = role_link._mentor_profile_created + # If this was an Intern role assignment, tell the admin whether + # the UserInternGuildLink was freshly created or an existing record was reactivated. + if hasattr(role_link, '_intern_guild_created'): + response_data["intern_guild_created"] = role_link._intern_guild_created + # If this was a Company role assignment, tell the admin whether + # the Company profile was freshly created or an existing record was verified. + if hasattr(role_link, '_company_created'): + response_data["company_created"] = role_link._company_created + return CustomResponse( - general_message="Role Added Successfully" + general_message="Role Added Successfully", + response=response_data, ).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Roles'], description="Delete User Role.", + responses={200: dash_roles_serializer.UserRoleCreateSerializer}, + ) def delete(self, request): - serializer = dash_roles_serializer.UserRoleCreateSerializer( - data=request.data, context={"request": request} - ) + user_id_str = request.data.get("user_id") + role_id = request.data.get("role_id") - if not serializer.is_valid(): + try: + user_role_link = UserRoleLink.objects.select_related("role", "user").get( + role_id=role_id, user_id=user_id_str + ) + except UserRoleLink.DoesNotExist: return CustomResponse( - general_message=serializer.errors + general_message="Role link not found." ).get_failure_response() - user_id = request.data.get("user_id") - role_id = request.data.get("role_id") + role_title = user_role_link.role.title + user = user_role_link.user + admin_id = JWTUtils.fetch_user_id(request) + now = DateTimeUtils.get_current_utc_time() + + with transaction.atomic(): + user_role_link.delete() - user_role_link = UserRoleLink.objects.get(role_id=role_id, user_id=user_id) + # ── Mentor cleanup ────────────────────────────────────────────── + if role_title == RoleType.MENTOR.value: + from db.user import UserMentor + from db.task import UserIgLink + mentor_records = UserMentor.objects.filter( + user=user, status=UserMentor.Status.APPROVED + ) + for record in mentor_records: + record.status = UserMentor.Status.REJECTED + record.updated_by_id = admin_id + record.updated_at = now + record.save(update_fields=["status", "updated_by_id", "updated_at"]) + + if record.mentor_tier == UserMentor.MentorTier.IG_MENTOR: + UserIgLink.objects.filter( + user=user, + assignment_type=UserIgLink.AssignmentType.MENTOR, + ).update(is_active=False) + + if record.mentor_tier in ( + UserMentor.MentorTier.CAMPUS_MENTOR, + UserMentor.MentorTier.COMPANY_MENTOR, + ) and record.org: + from db.organization import UserOrganizationLink + UserOrganizationLink.objects.filter( + user=user, org=record.org + ).update(verified=False) + + # ── Intern cleanup ────────────────────────────────────────────── + elif role_title == RoleType.INTERN.value: + from db.intern import UserInternGuildLink + from utils.types import InternGuildStatus + UserInternGuildLink.objects.filter(user=user).update( + status=InternGuildStatus.INACTIVE.value, + updated_by_id=admin_id, + ) - user_role_link.delete() + # ── Company cleanup ────────────────────────────────────────────── + elif role_title == RoleType.COMPANY.value: + from db.company import Company + Company.objects.filter( + company_user_id=user.id, status="verified" + ).update(status="suspended") DiscordWebhooks.general_updates( WebHookCategory.USER_ROLE.value, @@ -317,6 +456,9 @@ def delete(self, request): class RoleBaseTemplateAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Roles'], description="Retrieve Role Base Template.", + responses={200: OpenApiResponse(description="XLSX file download")}, + ) def get(self, request): wb = load_workbook("./excel-templates/role_base_template.xlsx") ws = wb["Data Definitions"] @@ -346,6 +488,12 @@ class UserRoleBulkAssignAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Roles'], + description="Create User Role Bulk Assign.", + request=dash_roles_serializer.UserRoleBulkAssignSerializer, + responses={200: dash_roles_serializer.UserRoleBulkAssignSerializer}, + ) def post(self, request): try: file_obj = request.FILES["user_roles_list"] diff --git a/api/dashboard/skill/__init__.py b/api/dashboard/skill/__init__.py new file mode 100644 index 000000000..9f3457b3c --- /dev/null +++ b/api/dashboard/skill/__init__.py @@ -0,0 +1 @@ +# Skill module diff --git a/api/dashboard/skill/skill_serializer.py b/api/dashboard/skill/skill_serializer.py new file mode 100644 index 000000000..dc0d643b2 --- /dev/null +++ b/api/dashboard/skill/skill_serializer.py @@ -0,0 +1,49 @@ +from rest_framework import serializers +from db.skill import Skill, TaskSkillLink + + +class SkillSerializer(serializers.ModelSerializer): + """Full skill serializer for CRUD operations""" + + class Meta: + model = Skill + fields = [ + 'id', 'name', 'code', 'description', 'icon', 'is_active', + 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class SkillDropdownSerializer(serializers.ModelSerializer): + """Minimal serializer for dropdown selections""" + + class Meta: + model = Skill + fields = ['id', 'name', 'code'] + + +class SkillCreateSerializer(serializers.Serializer): + """Serializer for skill creation""" + name = serializers.CharField(max_length=75) + code = serializers.CharField(max_length=20) + description = serializers.CharField(required=False, allow_blank=True) + icon = serializers.CharField(max_length=100, required=False, allow_blank=True) + is_active = serializers.BooleanField(default=True) + + +class SkillUpdateSerializer(serializers.Serializer): + """Serializer for skill update""" + name = serializers.CharField(max_length=75, required=False) + code = serializers.CharField(max_length=20, required=False) + description = serializers.CharField(required=False, allow_blank=True) + icon = serializers.CharField(max_length=100, required=False, allow_blank=True) + is_active = serializers.BooleanField(required=False) + + +class TaskSkillLinkSerializer(serializers.ModelSerializer): + """Serializer for task-skill links""" + skill = SkillDropdownSerializer(read_only=True) + + class Meta: + model = TaskSkillLink + fields = ['id', 'skill', 'created_at'] diff --git a/api/dashboard/skill/skill_views.py b/api/dashboard/skill/skill_views.py new file mode 100644 index 000000000..7f0d6f3ef --- /dev/null +++ b/api/dashboard/skill/skill_views.py @@ -0,0 +1,310 @@ +import uuid + +from rest_framework.views import APIView +from rest_framework.generics import get_object_or_404 + +from db.skill import Skill, TaskSkillLink +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.types import RoleType + +from drf_spectacular.utils import extend_schema +from .skill_serializer import ( + SkillSerializer, + SkillDropdownSerializer, + SkillCreateSerializer, + SkillUpdateSerializer, +) + + +class SkillListAPI(APIView): + """List all skills with pagination and search""" + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Skill'], + description="Retrieve Skill List.", + responses={200: SkillSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Unauthorized" + ).get_failure_response() + + # Get query params + search = request.query_params.get('search', '') + per_page = int(request.query_params.get('perPage', 10)) + page = int(request.query_params.get('pageIndex', 1)) + sort_by = request.query_params.get('sortBy', 'name') or 'name' + is_asc = request.query_params.get('isAsc', 'true').lower() == 'true' + active_only = request.query_params.get('activeOnly', 'false').lower() == 'true' + + # Base query + skills = Skill.objects.all() + + if active_only: + skills = skills.filter(is_active=True) + + if search: + skills = skills.filter(name__icontains=search) + + # Count total before pagination + total_count = skills.count() + + # Sorting + order_prefix = '' if is_asc else '-' + skills = skills.order_by(f'{order_prefix}{sort_by}') + + # Pagination + start = (page - 1) * per_page + end = start + per_page + skills = skills[start:end] + + serializer = SkillSerializer(skills, many=True) + + return CustomResponse( + response={ + "skills": serializer.data, + "pagination": { + "totalRecords": total_count, + "currentPage": page, + "perPage": per_page, + "totalPages": (total_count + per_page - 1) // per_page, + } + } + ).get_success_response() + + +class SkillDropdownAPI(APIView): + """Get skills for dropdown selection""" + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Skill'], + description="Retrieve Skill Dropdown.", + responses={200: SkillDropdownSerializer}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Unauthorized" + ).get_failure_response() + + skills = Skill.objects.filter(is_active=True).order_by('name') + serializer = SkillDropdownSerializer(skills, many=True) + + return CustomResponse(response=serializer.data).get_success_response() + + +class SkillCreateAPI(APIView): + """Create a new skill""" + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Skill'], + description="Create Skill Create.", + request=SkillCreateSerializer, + responses={200: SkillSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Unauthorized" + ).get_failure_response() + + serializer = SkillCreateSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + general_message="Validation error", + response=serializer.errors + ).get_failure_response() + + data = serializer.validated_data + + # Check for duplicate name or code + if Skill.objects.filter(name=data['name']).exists(): + return CustomResponse( + general_message=f"Skill with name '{data['name']}' already exists" + ).get_failure_response() + + if Skill.objects.filter(code=data['code']).exists(): + return CustomResponse( + general_message=f"Skill with code '{data['code']}' already exists" + ).get_failure_response() + + skill = Skill.objects.create( + id=str(uuid.uuid4()), + name=data['name'], + code=data['code'].upper(), + description=data.get('description', ''), + icon=data.get('icon', ''), + is_active=data.get('is_active', True), + created_by_id=user_id, + updated_by_id=user_id, + ) + + return CustomResponse( + general_message="Skill created successfully", + response=SkillSerializer(skill).data + ).get_success_response() + + +class SkillDetailAPI(APIView): + """Get, update, or delete a specific skill""" + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Skill'], + description="Retrieve Skill Detail.", + responses={200: SkillSerializer}, + ) + def get(self, request, skill_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Unauthorized" + ).get_failure_response() + + skill = get_object_or_404(Skill, id=skill_id) + serializer = SkillSerializer(skill) + + # Get task count for this skill + task_count = TaskSkillLink.objects.filter(skill_id=skill_id).count() + + response_data = serializer.data + response_data['task_count'] = task_count + + return CustomResponse(response=response_data).get_success_response() + + @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Skill'], + description="Update Skill Detail.", + request=SkillUpdateSerializer, + responses={200: SkillSerializer}, + ) + def put(self, request, skill_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Unauthorized" + ).get_failure_response() + + skill = get_object_or_404(Skill, id=skill_id) + serializer = SkillUpdateSerializer(data=request.data) + + if not serializer.is_valid(): + return CustomResponse( + general_message="Validation error", + response=serializer.errors + ).get_failure_response() + + data = serializer.validated_data + + # Check for duplicate name (excluding current skill) + if 'name' in data and data['name'] != skill.name: + if Skill.objects.filter(name=data['name']).exclude(id=skill_id).exists(): + return CustomResponse( + general_message=f"Skill with name '{data['name']}' already exists" + ).get_failure_response() + + # Check for duplicate code (excluding current skill) + if 'code' in data and data['code'] != skill.code: + if Skill.objects.filter(code=data['code']).exclude(id=skill_id).exists(): + return CustomResponse( + general_message=f"Skill with code '{data['code']}' already exists" + ).get_failure_response() + + # Update fields + if 'name' in data: + skill.name = data['name'] + if 'code' in data: + skill.code = data['code'].upper() + if 'description' in data: + skill.description = data['description'] + if 'icon' in data: + skill.icon = data['icon'] + if 'is_active' in data: + skill.is_active = data['is_active'] + + skill.updated_by_id = user_id + skill.save() + + return CustomResponse( + general_message="Skill updated successfully", + response=SkillSerializer(skill).data + ).get_success_response() + + @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Skill'], description="Delete Skill Detail.", + responses={200: SkillSerializer}, + ) + def delete(self, request, skill_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Unauthorized" + ).get_failure_response() + + skill = get_object_or_404(Skill, id=skill_id) + + # Check if skill has linked tasks + task_count = TaskSkillLink.objects.filter(skill_id=skill_id).count() + if task_count > 0: + return CustomResponse( + general_message=f"Cannot delete skill. It is linked to {task_count} task(s). Remove the links first or deactivate the skill." + ).get_failure_response() + + skill_name = skill.name + skill.delete() + + return CustomResponse( + general_message=f"Skill '{skill_name}' deleted successfully" + ).get_success_response() + + +class SkillTasksAPI(APIView): + """Get all tasks linked to a specific skill""" + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Dashboard - Skill'], + description="Retrieve Skill Tasks.", + responses={200: SkillSerializer}, + ) + def get(self, request, skill_id): + user_id = JWTUtils.fetch_user_id(request) + if not user_id: + return CustomResponse( + general_message="Unauthorized" + ).get_failure_response() + + skill = get_object_or_404(Skill, id=skill_id) + + # Get all tasks linked to this skill + task_links = TaskSkillLink.objects.filter( + skill_id=skill_id + ).select_related('task') + + tasks = [] + for link in task_links: + tasks.append({ + 'id': link.task.id, + 'title': link.task.title, + 'hashtag': link.task.hashtag, + 'karma': link.task.karma, + 'active': link.task.active, + }) + + return CustomResponse( + response={ + 'skill': SkillSerializer(skill).data, + 'tasks': tasks, + 'task_count': len(tasks) + } + ).get_success_response() diff --git a/api/dashboard/skill/urls.py b/api/dashboard/skill/urls.py new file mode 100644 index 000000000..8c5ab8d4d --- /dev/null +++ b/api/dashboard/skill/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import skill_views + +urlpatterns = [ + # Skill CRUD + path('', skill_views.SkillListAPI.as_view(), name='skill-list'), + path('create/', skill_views.SkillCreateAPI.as_view(), name='skill-create'), + path('dropdown/', skill_views.SkillDropdownAPI.as_view(), name='skill-dropdown'), + path('/', skill_views.SkillDetailAPI.as_view(), name='skill-detail'), + path('/tasks/', skill_views.SkillTasksAPI.as_view(), name='skill-tasks'), +] diff --git a/api/dashboard/task/dash_task_serializer.py b/api/dashboard/task/dash_task_serializer.py index 405c2b3f2..fed1dbb8b 100644 --- a/api/dashboard/task/dash_task_serializer.py +++ b/api/dashboard/task/dash_task_serializer.py @@ -13,6 +13,7 @@ class TaskListPublicSerializer(serializers.ModelSerializer): type = serializers.CharField(source="type.title") level = serializers.CharField(source="level.name", required=False, default=None) ig = serializers.CharField(source="ig.name", required=False, default=None) + event_id = serializers.CharField(source="event_fk_id", required=False, allow_null=True) class Meta: model = TaskList @@ -29,6 +30,7 @@ class Meta: "level", "ig", "event", + "event_id", ] @@ -38,7 +40,9 @@ class TaskListSerializer(serializers.ModelSerializer): level = serializers.CharField(source="level.name", required=False, default=None) ig = serializers.CharField(source="ig.name", required=False, default=None) org = serializers.CharField(source="org.title", required=False, default=None) + event_id = serializers.CharField(source="event_fk_id", required=False, allow_null=True) total_karma_gainers = serializers.SerializerMethodField() + skills = serializers.SerializerMethodField() created_by = serializers.CharField(source="created_by.full_name") updated_by = serializers.CharField(source="updated_by.full_name") @@ -61,19 +65,32 @@ class Meta: "org", "ig", "event", + "event_id", "updated_at", "updated_by", "created_by", "created_at", "bonus_time", "bonus_karma", + "skills", ] def get_total_karma_gainers(self, obj): + if hasattr(obj, 'total_karma_gainers_count'): + return obj.total_karma_gainers_count return obj.karma_activity_log_task.filter(appraiser_approved=True).count() + def get_skills(self, obj): + """Get all skills linked to this task""" + return [ + {'id': link.skill.id, 'name': link.skill.name, 'code': link.skill.code} + for link in obj.skill_links.all() + ] + class TaskModifySerializer(serializers.ModelSerializer): + event_id = serializers.CharField(source="event_fk_id", required=False, allow_null=True) + class Meta: model = TaskList fields = ( @@ -95,6 +112,7 @@ class Meta: "created_by", "bonus_karma", "bonus_time", + "event_id", ) @@ -187,6 +205,6 @@ def update(self, instance, validated_data): instance.title = updated_title user_id = JWTUtils.fetch_user_id(self.context.get("request")) instance.updated_by_id = user_id - instance.updated_at = (DateTimeUtils.get_current_utc_time(),) + instance.updated_at = DateTimeUtils.get_current_utc_time() instance.save() return instance diff --git a/api/dashboard/task/dash_task_view.py b/api/dashboard/task/dash_task_view.py index 18900ff1c..a2d0b6111 100644 --- a/api/dashboard/task/dash_task_view.py +++ b/api/dashboard/task/dash_task_view.py @@ -1,12 +1,16 @@ import uuid +from django.db.models import Count, Q +from rest_framework import status from rest_framework.views import APIView -from db.organization import Organization -from db.task import Channel, InterestGroup, Level, TaskList, TaskType +from db.organization import Organization, UserOrganizationLink +from db.task import Channel, InterestGroup, Level, TaskList, TaskType, UserIgLink +from db.user import UserMentor +from db.skill import Skill, TaskSkillLink from utils.permission import CustomizePermission, JWTUtils, role_required from utils.response import CustomResponse -from utils.types import Events, RoleType +from utils.types import Events, OrganizationType, RoleType from utils.utils import CommonUtils, DateTimeUtils, ImportCSV from .dash_task_serializer import ( TaskImportSerializer, @@ -21,20 +25,173 @@ from tempfile import NamedTemporaryFile from io import BytesIO from django.http import FileResponse +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse, OpenApiParameter +from drf_spectacular.openapi import OpenApiTypes +from rest_framework import serializers as s +from utils.schema_utils import CustomResponseSerializer class TaskPublicListAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - Task'], + description="Retrieve Task Public List.", + parameters=[ + OpenApiParameter( + "task_source", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + enum=["company", "ig_mentor", "campus_mentor", "platform"], + description=( + "Filter tasks by creator type: " + "'company' = tasks submitted by a verified company user, " + "'ig_mentor' = tasks submitted by an approved IG mentor, " + "'campus_mentor' = tasks submitted by an approved campus mentor, " + "'platform' = tasks created by platform admins. " + "Eligibility (IG/campus membership) is enforced by the existing visibility rules." + ), + ), + ], + responses={200: inline_serializer("TaskPublicListResponse", fields={ + "data": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + })}, + ) def get(self, request): + # Local imports to avoid circular import issues + from api.dashboard.events.public_views import _get_viewer_id, _build_scope_filter + from api.dashboard.events.serializers import get_live_events + from db.events import Event + + # Change 2: include event_fk so we can access event data without extra queries task_queryset = TaskList.objects.select_related( - "channel", "type", "level", "ig", "org" - ).all() + "channel", "type", "level", "ig", "org", "event_fk", "requested_by" + ).filter(active=True) + + # --- Visibility filtering --- + # Determine which orgs/IGs the current user belongs to (if authenticated). + # Unauthenticated users can only see global tasks and company tasks. + campus_org_types = [OrganizationType.COLLEGE.value, OrganizationType.SCHOOL.value] + + if JWTUtils.is_logged_in(request): + user_id = JWTUtils.fetch_user_id(request) + + learner_org_ids = list( + UserOrganizationLink.objects.filter( + user_id=user_id, + verified=True, + org__org_type__in=campus_org_types, + ).values_list("org_id", flat=True) + ) + + learner_ig_ids = list( + UserIgLink.objects.filter( + user_id=user_id, + is_active=True, + ).values_list("ig_id", flat=True) + ) + + # Org visibility rule: + # - No org set (global task) → visible to all + # - Org is a Company → visible to all + # - Org is a Campus (College/School) → only learners of that campus + org_filter = ( + Q(org__isnull=True) + | Q(org__org_type=OrganizationType.COMPANY.value) + | Q(org__org_type__in=campus_org_types, org_id__in=learner_org_ids) + ) + + # IG visibility rule: + # - No IG set → visible to all + # - IG is set → only learners in that IG + ig_filter = Q(ig__isnull=True) | Q(ig_id__in=learner_ig_ids) + + # Change 3: build the set of event IDs the caller is permitted to access, + # using the same scope logic as EventTaskPublicListAPI. + # _get_viewer_id returns None for unauthenticated callers (safe to use). + # _build_scope_filter returns GLOBAL-only when user_id is None. + viewer_id = _get_viewer_id(request) + scope_filter = _build_scope_filter(viewer_id) + accessible_event_ids = list( + get_live_events() + .filter( + scope_filter, + status__in=[Event.Status.PUBLISHED, Event.Status.ONGOING], + ) + .values_list("id", flat=True) + ) + + # Change 4: new query param filters + updated visibility logic + event_id_param = request.query_params.get("event_id") + is_event_task_only = ( + request.query_params.get("is_event_task", "").lower() == "true" + or request.query_params.get("event_tasks_only", "").lower() == "true" + ) + + if event_id_param: + # Filter to a single event — only if that event is accessible to the caller. + # Use .exists() on the DB to avoid lazy queryset / type-mismatch issues. + event_accessible = get_live_events().filter( + scope_filter, + status__in=[Event.Status.PUBLISHED, Event.Status.ONGOING], + id=event_id_param, + ).exists() + if event_accessible: + task_queryset = task_queryset.filter(event_fk_id=event_id_param) + else: + task_queryset = task_queryset.none() + + elif is_event_task_only: + # Return only event-linked tasks the caller can access + task_queryset = task_queryset.filter( + event_fk_id__in=accessible_event_ids + ) + + else: + # Default: show regular tasks (existing org/ig rules) PLUS + # event-linked tasks filtered by event scope visibility. + if JWTUtils.is_logged_in(request): + regular_tasks_filter = Q(event_fk__isnull=True) & org_filter & ig_filter + else: + # Unauthenticated: global + company tasks with no IG restriction + regular_tasks_filter = Q( + event_fk__isnull=True, + ig__isnull=True, + ) & (Q(org__isnull=True) | Q(org__org_type=OrganizationType.COMPANY.value)) + + event_tasks_filter = Q( + event_fk__isnull=False, + event_fk_id__in=accessible_event_ids, + ) + task_queryset = task_queryset.filter(regular_tasks_filter | event_tasks_filter) ig_id = request.query_params.get("ig_id") if ig_id: task_queryset = task_queryset.filter(ig_id=ig_id) + task_source = request.query_params.get("task_source") + if task_source == "company": + task_queryset = task_queryset.filter( + requested_by__isnull=False, + requested_by__company_profile__isnull=False, + ) + elif task_source == "ig_mentor": + task_queryset = task_queryset.filter( + requested_by__isnull=False, + requested_by__user_mentor_user__mentor_tier=UserMentor.MentorTier.IG_MENTOR, + requested_by__user_mentor_user__status=UserMentor.Status.APPROVED, + ).distinct() + elif task_source == "campus_mentor": + task_queryset = task_queryset.filter( + requested_by__isnull=False, + requested_by__user_mentor_user__mentor_tier=UserMentor.MentorTier.CAMPUS_MENTOR, + requested_by__user_mentor_user__status=UserMentor.Status.APPROVED, + ).distinct() + elif task_source == "platform": + task_queryset = task_queryset.filter(requested_by__isnull=True) + paginated_queryset = CommonUtils.get_paginated_queryset( task_queryset, request, @@ -52,6 +209,7 @@ def get(self, request): "org__title", "ig__name", "event", + "event_fk__title", # Change 5: search by linked event title ], sort_fields={ "hashtag": "hashtag", @@ -67,6 +225,7 @@ def get(self, request): "org": "org__title", "ig": "ig__name", "event": "event", + "event_title": "event_fk__title", # Change 6: sort by linked event title "updated_at": "updated_at", "created_at": "created_at", }, @@ -92,10 +251,59 @@ class TaskListAPI(APIView): RoleType.ASSOCIATE.value, ] ) + @extend_schema( + tags=['Dashboard - Task'], + description="Retrieve Task List.", + parameters=[ + OpenApiParameter( + "task_source", + OpenApiTypes.STR, + OpenApiParameter.QUERY, + required=False, + enum=["company", "ig_mentor", "campus_mentor", "platform"], + description=( + "Filter tasks by creator type: " + "'company' = tasks submitted by a verified company user, " + "'ig_mentor' = tasks submitted by an approved IG mentor, " + "'campus_mentor' = tasks submitted by an approved campus mentor, " + "'platform' = tasks created by platform admins." + ), + ), + ], + responses={200: inline_serializer("TaskListResponse", fields={ + "data": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + })}, + ) def get(self, request): task_queryset = TaskList.objects.select_related( - "created_by", "updated_by", "channel", "type", "level", "ig", "org" - ).all() + "created_by", "updated_by", "channel", "type", "level", "ig", "org", "requested_by" + ).prefetch_related( + "skill_links__skill" + ).annotate( + total_karma_gainers_count=Count("karma_activity_log_task", filter=Q(karma_activity_log_task__appraiser_approved=True)) + ).filter(active=True) + + task_source = request.query_params.get("task_source") + if task_source == "company": + task_queryset = task_queryset.filter( + requested_by__isnull=False, + requested_by__company_profile__isnull=False, + ) + elif task_source == "ig_mentor": + task_queryset = task_queryset.filter( + requested_by__isnull=False, + requested_by__user_mentor_user__mentor_tier=UserMentor.MentorTier.IG_MENTOR, + requested_by__user_mentor_user__status=UserMentor.Status.APPROVED, + ).distinct() + elif task_source == "campus_mentor": + task_queryset = task_queryset.filter( + requested_by__isnull=False, + requested_by__user_mentor_user__mentor_tier=UserMentor.MentorTier.CAMPUS_MENTOR, + requested_by__user_mentor_user__status=UserMentor.Status.APPROVED, + ).distinct() + elif task_source == "platform": + task_queryset = task_queryset.filter(requested_by__isnull=True) paginated_queryset = CommonUtils.get_paginated_queryset( task_queryset, @@ -156,22 +364,57 @@ def get(self, request): RoleType.ASSOCIATE.value, ] ) + @extend_schema( + tags=['Dashboard - Task'], + description="Create Task List.", + request=TaskModifySerializer, + responses={200: OpenApiResponse(description="Task Created Successfully")}, + ) def post(self, request): # create user_id = JWTUtils.fetch_user_id(request) mutable_data = request.data.copy() # Create a mutable copy of request.data mutable_data["created_by"] = user_id mutable_data["updated_by"] = user_id + + # Extract skill_ids before serializer processing + skill_ids = mutable_data.pop("skill_ids", None) + if isinstance(skill_ids, str): + import json + try: + skill_ids = json.loads(skill_ids) + except: + skill_ids = [] serializer = TaskModifySerializer(data=mutable_data) if not serializer.is_valid(): return CustomResponse(message=serializer.errors).get_failure_response() - serializer.save() + task = serializer.save() + + # Handle skill links + if skill_ids: + self._save_task_skills(task.id, skill_ids, user_id) + return CustomResponse( general_message="Task Created Successfully" ).get_success_response() + + def _save_task_skills(self, task_id, skill_ids, user_id): + """Save skill links for a task""" + # Clear existing links + TaskSkillLink.objects.filter(task_id=task_id).delete() + + # Create new links + for skill_id in skill_ids: + if Skill.objects.filter(id=skill_id, is_active=True).exists(): + TaskSkillLink.objects.create( + id=str(uuid.uuid4()), + task_id=task_id, + skill_id=skill_id, + created_by_id=user_id, + ) class TaskAPI(APIView): @@ -184,8 +427,19 @@ class TaskAPI(APIView): RoleType.ASSOCIATE.value, ] ) + @extend_schema( + tags=['Dashboard - Task'], + description="Retrieve Task.", + responses={200: TaskModifySerializer}, + ) def get(self, request, task_id): - task_queryset = TaskList.objects.get(pk=task_id) + try: + task_queryset = TaskList.objects.get(pk=task_id) + except TaskList.DoesNotExist: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404, http_status_code=status.HTTP_404_NOT_FOUND) + task_serializer = TaskModifySerializer(task_queryset, many=False) return CustomResponse(response=task_serializer.data).get_success_response() @@ -196,13 +450,30 @@ def get(self, request, task_id): RoleType.ASSOCIATE.value, ] ) + @extend_schema(tags=['Dashboard - Task'], description="Update Task.", + responses={200: TaskModifySerializer}, + ) def put(self, request, task_id): # edit user_id = JWTUtils.fetch_user_id(request) mutable_data = request.data.copy() # Create a mutable copy of request.data mutable_data["updated_by"] = user_id + + # Extract skill_ids before serializer processing + skill_ids = mutable_data.pop("skill_ids", None) + if isinstance(skill_ids, str): + import json + try: + skill_ids = json.loads(skill_ids) + except: + skill_ids = None - task = TaskList.objects.get(pk=task_id) + try: + task = TaskList.objects.get(pk=task_id) + except TaskList.DoesNotExist: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404, http_status_code=status.HTTP_404_NOT_FOUND) serializer = TaskModifySerializer(task, data=mutable_data, partial=True) @@ -210,8 +481,27 @@ def put(self, request, task_id): # edit return CustomResponse(message=serializer.errors).get_failure_response() serializer.save() + + # Handle skill links if provided + if skill_ids is not None: + self._save_task_skills(task_id, skill_ids, user_id) return CustomResponse(general_message=serializer.data).get_success_response() + + def _save_task_skills(self, task_id, skill_ids, user_id): + """Save skill links for a task""" + # Clear existing links + TaskSkillLink.objects.filter(task_id=task_id).delete() + + # Create new links + for skill_id in skill_ids: + if Skill.objects.filter(id=skill_id, is_active=True).exists(): + TaskSkillLink.objects.create( + id=str(uuid.uuid4()), + task_id=task_id, + skill_id=skill_id, + created_by_id=user_id, + ) @role_required( [ @@ -220,8 +510,17 @@ def put(self, request, task_id): # edit RoleType.ASSOCIATE.value, ] ) + @extend_schema(tags=['Dashboard - Task'], description="Delete Task.", + responses={200: TaskModifySerializer}, + ) def delete(self, request, task_id): # delete - task = TaskList.objects.get(id=task_id) + try: + task = TaskList.objects.get(id=task_id) + except TaskList.DoesNotExist: + return CustomResponse( + general_message="Task not found." + ).get_failure_response(status_code=404, http_status_code=status.HTTP_404_NOT_FOUND) + task.delete() return CustomResponse( @@ -239,10 +538,63 @@ class TaskListCSV(APIView): RoleType.ASSOCIATE.value, ] ) + @extend_schema( + tags=['Dashboard - Task'], + description="Retrieve Task List C S V.", + responses={200: OpenApiResponse(description="CSV file download of the task list")}, + ) def get(self, request): task_queryset = TaskList.objects.select_related( "created_by", "updated_by", "channel", "type", "level", "ig", "org" - ).all() + ).prefetch_related( + "skill_links__skill" + ).annotate( + total_karma_gainers_count=Count("karma_activity_log_task", filter=Q(karma_activity_log_task__appraiser_approved=True)) + ).filter(active=True) + + task_queryset = CommonUtils.get_paginated_queryset( + task_queryset, + request, + search_fields=[ + "hashtag", + "title", + "description", + "karma", + "channel__name", + "type__title", + "active", + "variable_karma", + "usage_count", + "level__name", + "org__title", + "ig__name", + "event", + "updated_at", + "updated_by__full_name", + "created_by__full_name", + "created_at", + ], + sort_fields={ + "hashtag": "hashtag", + "title": "title", + "description": "description", + "karma": "karma", + "channels": "channel__name", + "type": "type__title", + "active": "active", + "variable_karma": "variable_karma", + "usage_count": "usage_count", + "level": "level__name", + "org": "org__title", + "ig": "ig__name", + "event": "event", + "updated_at": "updated_at", + "updated_by": "updated_by__full_name", + "created_by": "created_by__full_name", + "created_at": "created_at", + }, + is_pagination=False, + ) task_serializer_data = TaskListSerializer(task_queryset, many=True).data @@ -259,10 +611,15 @@ class ImportTaskListCSV(APIView): RoleType.ASSOCIATE.value, ] ) + @extend_schema( + tags=['Dashboard - Task'], + description="Create Import Task List C S V.", + request=TaskImportSerializer, + responses={200: TaskImportSerializer}, + ) def post(self, request): - try: - file_obj = request.FILES["task_list"] - except KeyError: + file_obj = request.FILES.get("task_list", request.FILES.get("file")) + if not file_obj: return CustomResponse( general_message="File not found." ).get_failure_response() @@ -299,6 +656,7 @@ def post(self, request): excel_data = [row for row in excel_data if any(row.values())] valid_rows = [] error_rows = [] + rows_to_validate = [] hashtags_excel = set() hashtags_db = TaskList.objects.values_list("hashtag", flat=True) @@ -313,17 +671,14 @@ def post(self, request): if not hashtag: row["error"] = "Missing hashtag." error_rows.append(row) - excel_data.remove(row) continue elif hashtag in hashtags_excel: row["error"] = f"Duplicate hashtag in excel: {hashtag}" error_rows.append(row) - excel_data.remove(row) continue elif hashtag in hashtags_db: row["error"] = f"Duplicate hashtag in database: {hashtag}" error_rows.append(row) - excel_data.remove(row) continue else: hashtags_excel.add(hashtag) @@ -332,7 +687,6 @@ def post(self, request): if not title: row["error"] = "Missing title." error_rows.append(row) - excel_data.remove(row) continue level = row.get("level") @@ -346,6 +700,7 @@ def post(self, request): levels_to_fetch.add(level) igs_to_fetch.add(ig) orgs_to_fetch.add(org) + rows_to_validate.append(row) channels = Channel.objects.filter(name__in=channels_to_fetch).values( "id", "name" @@ -370,7 +725,7 @@ def post(self, request): orgs_dict = {org["code"]: org["id"] for org in orgs} events = Events.get_all_values() - for row in excel_data[1:]: + for row in rows_to_validate: level = row.pop("level") channel = row.pop("channel") task_type = row.pop("type") @@ -415,7 +770,15 @@ def post(self, request): row["level_id"] = level_id or None row["ig_id"] = ig_id or None row["org_id"] = org_id or None - valid_rows.append(row) + + valid_fields = [ + "id", "hashtag", "discord_link", "title", "description", + "karma", "channel_id", "type_id", "org_id", "event", "level_id", "ig_id", + "active", "variable_karma", "usage_count", "created_by_id", + "updated_by_id", "created_at", "updated_at" + ] + clean_row = {k: v for k, v in row.items() if k in valid_fields} + valid_rows.append(clean_row) task_list_serializer = TaskImportSerializer(data=valid_rows, many=True) success_data = [] @@ -454,8 +817,16 @@ class ChannelDropdownAPI(APIView): RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value, + RoleType.COMPANY.value, + RoleType.MENTOR.value, ] ) + @extend_schema(tags=['Dashboard - Task'], description="Retrieve Channel Dropdown.", + responses={200: inline_serializer("TaskChannelDropdownResponse", fields={ + "id": s.CharField(), + "name": s.CharField(), + })}, + ) def get(self, request): channels = Channel.objects.values("id", "name") @@ -470,8 +841,16 @@ class IGDropdownAPI(APIView): RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value, + RoleType.COMPANY.value, + RoleType.MENTOR.value, ] ) + @extend_schema(tags=['Dashboard - Task'], description="Retrieve I G Dropdown.", + responses={200: inline_serializer("TaskIGDropdownResponse", fields={ + "id": s.CharField(), + "name": s.CharField(), + })}, + ) def get(self, request): igs = InterestGroup.objects.values("id", "name") return CustomResponse(response=igs).get_success_response() @@ -485,8 +864,16 @@ class OrganizationDropdownAPI(APIView): RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value, + RoleType.COMPANY.value, + RoleType.MENTOR.value, ] ) + @extend_schema(tags=['Dashboard - Task'], description="Retrieve Organization Dropdown.", + responses={200: inline_serializer("TaskOrganizationDropdownResponse", fields={ + "id": s.CharField(), + "title": s.CharField(), + })}, + ) def get(self, request): organizations = Organization.objects.values("id", "title") return CustomResponse(response=organizations).get_success_response() @@ -500,10 +887,23 @@ class LevelDropdownAPI(APIView): RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value, + RoleType.COMPANY.value, + RoleType.MENTOR.value, ] ) + @extend_schema(tags=['Dashboard - Task'], description="Retrieve Level Dropdown.", + responses={200: inline_serializer("TaskLevelDropdownResponse", fields={ + "id": s.CharField(), + "name": s.CharField(), + "level_order": s.IntegerField(), + })}, + ) def get(self, request): - levels = Level.objects.values("id", "name") + # level_order is the numeric rank used by downstream consumers (e.g. job + # eligibility "Min/Max Level" rules, which compare against a user's + # level_order). Returning it lets clients store the order rather than an + # opaque id/name. + levels = Level.objects.values("id", "name", "level_order").order_by("level_order") return CustomResponse(response=levels).get_success_response() @@ -515,8 +915,13 @@ class TaskTypesDropDownAPI(APIView): RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value, + RoleType.COMPANY.value, + RoleType.MENTOR.value, ] ) + @extend_schema(tags=['Dashboard - Task'], description="Retrieve Task Types Drop Down.", + responses={200: TasktypeSerializer}, + ) def get(self, request): task_types = TaskType.objects.values("id", "title") return CustomResponse(response=task_types).get_success_response() @@ -530,6 +935,11 @@ class EventDropDownApi(APIView): RoleType.ADMIN.value, ] ) + @extend_schema(tags=['Dashboard - Task'], description="Retrieve Event Drop Down Api.", + responses={200: inline_serializer("TaskEventDropdownResponse", fields={ + "events": s.ListField(child=s.CharField()), + })}, + ) def get(self, request): events = Events.get_all_values() return CustomResponse(response=events).get_success_response() @@ -538,6 +948,9 @@ def get(self, request): class TaskBaseTemplateAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - Task'], description="Retrieve Task Base Template.", + responses={200: OpenApiResponse(description="Excel template file download (task_base_template.xlsx)")}, + ) def get(self, request): wb = load_workbook("./excel-templates/task_base_template.xlsx") ws = wb["Data Definitions"] @@ -580,8 +993,15 @@ class TaskTypeCrudAPI(APIView): @role_required( [ RoleType.ADMIN.value, + RoleType.COMPANY.value, + RoleType.MENTOR.value, ] ) + @extend_schema( + tags=['Dashboard - Task'], + description="Retrieve Task Type Crud.", + responses={200: TasktypeSerializer}, + ) def get(self, request): taskType = TaskType.objects.all() paginated_queryset = CommonUtils.get_paginated_queryset( @@ -603,6 +1023,12 @@ def get(self, request): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Task'], + description="Create Task Type Crud.", + request=TaskTypeCreateUpdateSerializer, + responses={200: TasktypeSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) serializer = TaskTypeCreateUpdateSerializer( @@ -617,6 +1043,9 @@ def post(self, request): return CustomResponse(general_message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Task'], description="Delete Task Type Crud.", + responses={200: TasktypeSerializer}, + ) def delete(self, request, task_type_id): taskType = TaskType.objects.filter(id=task_type_id).first() if taskType is None: @@ -629,6 +1058,11 @@ def delete(self, request, task_type_id): ).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - Task'], + description="Update Task Type Crud.", + responses={200: TaskTypeCreateUpdateSerializer}, + ) def put(self, request, task_type_id): taskType = TaskType.objects.filter(id=task_type_id).first() if taskType is None: @@ -644,3 +1078,192 @@ def put(self, request, task_type_id): general_message=f"{taskType.title} updated successfully" ).get_success_response() return CustomResponse(response=serializer.errors).get_failure_response() + + +# --------------------------------------------------------------------------- +# Admin: Task Approval Workflow (for company-submitted tasks) +# --------------------------------------------------------------------------- + +class AdminTaskApprovalAPI(APIView): + """ + GET /dashboard/task/pending/ — list all tasks awaiting admin review + PATCH /dashboard/task//approve/ — approve a pending task (goes live) + PATCH /dashboard/task//reject/ — reject with a reason + + All actions require the Admin role. + """ + authentication_classes = [CustomizePermission] + + @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Task'], description="Retrieve Admin Task Approval.", + responses={200: inline_serializer("TaskAdminApprovalListResponse", fields={ + "tasks": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + })}, + ) + def get(self, request): + """List tasks with filtering.""" + from django.utils import timezone as tz + + queryset = ( + TaskList.objects + .select_related("ig", "type", "requested_by", "requested_by__company_profile") + .order_by("created_at") + ) + + approval_status = request.query_params.get("approval_status", "pending") + if approval_status: + queryset = queryset.filter(approval_status=approval_status) + + source = request.query_params.get("source") + role = request.query_params.get("role") or source + + if role == "mentor": + queryset = queryset.filter( + requested_by__isnull=False, + requested_by__user_role_link_user__role__title=RoleType.MENTOR.value + ) + elif role == "company": + queryset = queryset.filter( + requested_by__isnull=False, + requested_by__user_role_link_user__role__title=RoleType.COMPANY.value + ) + elif role == "admin": + queryset = queryset.filter(requested_by__isnull=True) + + company_name = request.query_params.get("company_name") + if company_name: + queryset = queryset.filter(requested_by__company_profile__name__icontains=company_name) + + mentor_name = request.query_params.get("mentor_name") + if mentor_name: + queryset = queryset.filter( + requested_by__full_name__icontains=mentor_name, + requested_by__user_role_link_user__role__title=RoleType.MENTOR.value + ) + + paginated = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=[ + "title", "hashtag", + "requested_by__company_profile__name", + "requested_by__full_name", + ], + sort_fields={"createdAt": "created_at", "title": "title"}, + is_pagination=True, + ) + + data = [] + for task in paginated["queryset"]: + company_profile = getattr(task.requested_by, "company_profile", None) if task.requested_by else None + data.append({ + "id": str(task.id), + "title": task.title, + "hashtag": task.hashtag, + "description": task.description, + "karma": task.karma, + "approval_status": task.approval_status, + "ig": {"id": str(task.ig.id), "name": task.ig.name} if task.ig else None, + "type": {"id": str(task.type.id), "title": task.type.title} if task.type else None, + "company_name": company_profile.name if company_profile else None, + "requested_by": { + "id": str(task.requested_by.id), + "full_name": task.requested_by.full_name, + } if task.requested_by else None, + "requested_at": task.requested_at.isoformat() if task.requested_at else None, + "created_at": task.created_at.isoformat(), + }) + + return CustomResponse( + general_message="Tasks fetched successfully.", + response={"tasks": data, "pagination": paginated["pagination"]}, + ).get_success_response() + + @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - Task'], description="Partially update Admin Task Approval.", + responses={200: inline_serializer("TaskAdminApprovalActionResponse", fields={ + "task_id": s.CharField(), + "approval_status": s.CharField(), + "active": s.BooleanField(), + "rejection_reason": s.CharField(allow_null=True), + "reviewed_by": s.CharField(allow_null=True), + "reviewed_at": s.CharField(allow_null=True), + })}, + ) + def patch(self, request, task_id): + """ + Approve or reject a pending task. + Body: { "action": "approve" | "reject", "reason": "" } + """ + from django.utils import timezone as tz + + action = request.data.get("action") + if action not in ("approve", "reject"): + return CustomResponse( + general_message="Invalid action. Must be 'approve' or 'reject'.", + message={"error_code": "INVALID_ACTION"}, + ).get_failure_response() + + try: + task = TaskList.objects.get(id=task_id) + except TaskList.DoesNotExist: + return CustomResponse( + general_message="Task not found.", + message={"error_code": "TASK_NOT_FOUND"}, + ).get_failure_response() + + if task.approval_status != "pending": + return CustomResponse( + general_message=f"Only pending tasks can be reviewed. Current status: '{task.approval_status}'.", + message={"error_code": "INVALID_STATUS_TRANSITION"}, + ).get_failure_response() + + admin_user_id = JWTUtils.fetch_user_id(request) + from db.user import User as UserModel + admin_user = UserModel.objects.filter(id=admin_user_id).first() + + now = tz.now() + + if action == "approve": + task.approval_status = "approved" + task.active = True + task.rejection_reason = None + task.reviewed_by_admin = admin_user + task.reviewed_at = now + task.updated_by = admin_user + task.save(update_fields=[ + "approval_status", "active", "rejection_reason", + "reviewed_by_admin", "reviewed_at", "updated_by", "updated_at", + ]) + message = "Task approved and is now live." + else: + reason = (request.data.get("reason") or "").strip() + if not reason: + return CustomResponse( + general_message="A rejection reason is required.", + message={"error_code": "REASON_REQUIRED"}, + ).get_failure_response() + task.approval_status = "rejected" + task.active = False + task.rejection_reason = reason + task.reviewed_by_admin = admin_user + task.reviewed_at = now + task.updated_by = admin_user + task.save(update_fields=[ + "approval_status", "active", "rejection_reason", + "reviewed_by_admin", "reviewed_at", "updated_by", "updated_at", + ]) + message = "Task rejected." + + return CustomResponse( + general_message=message, + response={ + "task_id": str(task.id), + "approval_status": task.approval_status, + "active": task.active, + "rejection_reason": task.rejection_reason, + "reviewed_by": str(admin_user.id) if admin_user else None, + "reviewed_at": task.reviewed_at.isoformat() if task.reviewed_at else None, + }, + ).get_success_response() diff --git a/api/dashboard/task/urls.py b/api/dashboard/task/urls.py index c8faba879..ba6f78929 100644 --- a/api/dashboard/task/urls.py +++ b/api/dashboard/task/urls.py @@ -21,5 +21,8 @@ path("import/", dash_task_view.ImportTaskListCSV.as_view()), path("base-template/", dash_task_view.TaskBaseTemplateAPI.as_view()), path("events/", dash_task_view.EventDropDownApi.as_view()), + # Admin task approval workflow + path("pending/", dash_task_view.AdminTaskApprovalAPI.as_view(), name="admin-task-pending"), + path("/review/", dash_task_view.AdminTaskApprovalAPI.as_view(), name="admin-task-review"), path("/", dash_task_view.TaskAPI.as_view()), # get task, edit, delete ] diff --git a/api/dashboard/task_report/serializers.py b/api/dashboard/task_report/serializers.py new file mode 100644 index 000000000..b31fe8f85 --- /dev/null +++ b/api/dashboard/task_report/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + +from db.task import TaskReport +from utils.utils import DateTimeUtils +from utils.types import ManagementType + +class TaskReportSerializer(serializers.ModelSerializer): + reporter = serializers.CharField(source="reporter.fullname") + offender = serializers.CharField(source="offender.fullname") + created_at = serializers.SerializerMethodField() + updated_at = serializers.SerializerMethodField() + + class Meta: + model = TaskReport + fields = [ + "id", + "reporter", + "offender", + "message_id", + "reason", + "proof_link", + "status", + "created_at", + "updated_at", + ] + + def get_created_at(self, obj): + return DateTimeUtils.format_datetime(obj.created_at) + + def get_updated_at(self, obj): + return DateTimeUtils.format_datetime(obj.updated_at) + + +class TaskReportUpdateSerializer(serializers.ModelSerializer): + updated_by = serializers.CharField(required=False) + + class Meta: + model = TaskReport + fields = ["status", "updated_by"] + + def update(self, instance, validated_data): + instance.status = validated_data.get("status", instance.status) + instance.updated_by_id = self.context.get("user_id") + instance.updated_at = DateTimeUtils.get_current_utc_time() + instance.save() + return instance diff --git a/api/dashboard/task_report/urls.py b/api/dashboard/task_report/urls.py new file mode 100644 index 000000000..946691e75 --- /dev/null +++ b/api/dashboard/task_report/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.TaskReportInfoView.as_view()), + path('/', views.TaskReportInfoView.as_view()), + path('group-by-reporter/', views.TaskReportReporterGroupingView.as_view()), +] diff --git a/api/dashboard/task_report/views.py b/api/dashboard/task_report/views.py new file mode 100644 index 000000000..66ae89c3d --- /dev/null +++ b/api/dashboard/task_report/views.py @@ -0,0 +1,110 @@ +from rest_framework.views import APIView +from django.db.models import Count + +from db.task import TaskReport, KarmaActivityLog +from utils.response import CustomResponse +from utils.permission import CustomizePermission, JWTUtils, RoleRequired +from utils.types import RoleType +from . import serializers +from drf_spectacular.utils import extend_schema + +class TaskReportInfoView(APIView): + authentication_classes = [CustomizePermission] + + @RoleRequired(roles=[RoleType.ADMIN, RoleType.FELLOW]) + @extend_schema( + tags=['Dashboard - Task Report'], + description="Retrieve Task Report Info.", + responses={200: serializers.TaskReportSerializer}, + ) + def get(self, request): + reports = TaskReport.objects.all() + + # Filtering + if status := request.query_params.get("status"): + reports = reports.filter(status=status) + + serializer = serializers.TaskReportSerializer(reports, many=True) + return CustomResponse(response=serializer.data).get_success_response() + + @RoleRequired(roles=[RoleType.ADMIN, RoleType.FELLOW]) + @extend_schema( + tags=['Dashboard - Task Report'], + description="Update Task Report Info.", + responses={200: serializers.TaskReportUpdateSerializer}, + ) + def put(self, request, report_id): + report = TaskReport.objects.filter(id=report_id).first() + if not report: + return CustomResponse(general_message="Report not found").get_failure_response() + + serializer = serializers.TaskReportUpdateSerializer( + report, data=request.data, context={"user_id": JWTUtils.fetch_user_id(request)} + ) + if serializer.is_valid(): + serializer.save() + return CustomResponse(general_message="Report status updated successfully").get_success_response() + return CustomResponse(message=serializer.errors).get_failure_response() + + +class TaskReportTaskGroupingView(APIView): + authentication_classes = [CustomizePermission] + + @RoleRequired(roles=[RoleType.ADMIN, RoleType.FELLOW]) + @extend_schema(tags=['Dashboard - Task Report'], description="Retrieve Task Report Task Grouping.", + responses={200: TaskReportSerializer}, + ) + def get(self, request): + # 1) How many people reported to each tasks + # Group by message_id + report_counts = ( + TaskReport.objects.values("message_id") + .annotate(report_count=Count("id")) + .order_by("-report_count") + ) + + data = [] + for item in report_counts: + res = { + "message_id": item["message_id"], + "report_count": item["report_count"], + "task_hashtag": None + } + # Try to fetch hashtag from KarmaActivityLog + if kal := KarmaActivityLog.objects.filter(task_message_id=item["message_id"]).first(): + res["task_hashtag"] = kal.task.hashtag + + data.append(res) + + return CustomResponse(response=data).get_success_response() + + +class TaskReportReporterGroupingView(APIView): + authentication_classes = [CustomizePermission] + + @RoleRequired(roles=[RoleType.ADMIN, RoleType.FELLOW]) + @extend_schema(tags=['Dashboard - Task Report'], description="Retrieve Task Report Reporter Grouping.", + responses={200: TaskReportSerializer}, + ) + def get(self, request): + # Request #2: "How many tasks were reported by a person" - confusing phrasing + # It could mean "How many reports did User X make" OR "How many times was User Y reported" + # Let's provide both. + + # Reports by Reporter + reporters = ( + TaskReport.objects.values("reporter__id", "reporter__fullname") + .annotate(report_count=Count("id")) + .order_by("-report_count") + ) + + # Reports against Offender + offenders = ( + TaskReport.objects.values("offender__id", "offender__fullname") + .annotate(report_count=Count("id")) + .order_by("-report_count") + ) + + return CustomResponse( + response={"reporters": reporters, "offenders": offenders} + ).get_success_response() diff --git a/api/dashboard/urls.py b/api/dashboard/urls.py index ada6b63cf..0b3a03b0c 100644 --- a/api/dashboard/urls.py +++ b/api/dashboard/urls.py @@ -1,11 +1,15 @@ from django.urls import path, include +from api.calendar.dashboard_calendar_view import DashboardCalendarAPI # app_name will help us do a reverse look-up latter. urlpatterns = [ + path("calendar/events/", DashboardCalendarAPI.as_view()), + path("home/", include("api.dashboard.home.urls")), path("user/", include("api.dashboard.user.urls")), path("zonal/", include("api.dashboard.zonal.urls")), path("district/", include("api.dashboard.district.urls")), path("campus/", include("api.dashboard.campus.urls")), + path("enabler/", include("api.dashboard.enabler.urls")), path("roles/", include("api.dashboard.roles.urls")), path("ig/", include("api.dashboard.ig.urls")), path("task/", include("api.dashboard.task.urls")), @@ -26,4 +30,13 @@ path("coupon/", include("api.dashboard.coupon.urls")), path("projects/", include("api.dashboard.projects.urls")), path("achievement/", include("api.dashboard.achievement.urls")), + path("skill/", include("api.dashboard.skill.urls")), + path("media-content/", include("api.dashboard.media_content.urls")), + + path("category/", include("api.dashboard.category.urls")), + path("mentor/", include("api.dashboard.mentor.urls")), + path("intern/", include("api.dashboard.intern.urls")), + path("manage-interns/", include("api.dashboard.manage_interns.urls")), + path("company/", include("api.dashboard.company.urls")), + # mentorship/ has been consolidated into mentor/ — do not re-add ] diff --git a/api/dashboard/user/dash_user_serializer.py b/api/dashboard/user/dash_user_serializer.py index 63f1a6014..d24031711 100644 --- a/api/dashboard/user/dash_user_serializer.py +++ b/api/dashboard/user/dash_user_serializer.py @@ -1,15 +1,17 @@ +import re import uuid from decouple import config as decouple_config from django.db import transaction from rest_framework import serializers +from db.company import Company from db.organization import Department, Organization, UserOrganizationLink from db.task import UserIgLink -from db.user import Role, User, UserDomains, UserRoleLink +from db.user import Role, User, UserDomains, UserMentor, UserRoleLink from utils.permission import JWTUtils from utils.types import OrganizationType, RoleType -from utils.utils import DateTimeUtils +from utils.utils import DateTimeUtils, check_alumni_status from db.user import DynamicRole, DynamicUser # from db.user import UserInterests @@ -42,6 +44,7 @@ class UserSerializer(serializers.ModelSerializer): dynamic_type = serializers.SerializerMethodField() user_domains = serializers.SerializerMethodField() user_endgoals = serializers.SerializerMethodField() + company = serializers.SerializerMethodField() class Meta: model = User @@ -61,6 +64,7 @@ class Meta: "user_endgoals", "interested_in_work", "interested_in_gig_work", + "company", ] # def get_interest_selected(self, obj): @@ -80,7 +84,7 @@ def get_roles(self, obj): ] def get_dynamic_type(self, obj): - return { + dynamic_types = { dynamic_role.type for dynamic_role in DynamicRole.objects.filter( role__title__in=self.get_roles(obj) @@ -89,6 +93,18 @@ def get_dynamic_type(self, obj): {dynamic_user.type for dynamic_user in DynamicUser.objects.filter(user=obj)} ) + return sorted(dynamic_type for dynamic_type in dynamic_types if dynamic_type is not None) + + def get_company(self, obj): + roles = self.get_roles(obj) + if RoleType.COMPANY.value in roles: + company_link = obj.user_organization_link_user.filter( + org__org_type=OrganizationType.COMPANY.value + ).first() + if company_link: + return company_link.org.title + return None + class CollegeSerializer(serializers.ModelSerializer): org_type = serializers.CharField(source="org.org_type") @@ -186,6 +202,7 @@ def update(self, instance, validated_data): verified=True, department_id=department, graduation_year=graduation_year, + is_alumni=check_alumni_status(graduation_year), ) for org_id in orgs ] @@ -216,10 +233,7 @@ def get_organizations(self, user): organizations_data = [] for link in organization_links: - if ( - link.org.org_type == OrganizationType.COLLEGE.value - or OrganizationType.SCHOOL.value - ): + if link.org.org_type in (OrganizationType.COLLEGE.value, OrganizationType.SCHOOL.value): serializer = CollegeSerializer(link) else: serializer = OrgSerializer(link) @@ -235,36 +249,170 @@ def get_role(self, user): class UserVerificationSerializer(serializers.ModelSerializer): - full_name = serializers.ReadOnlyField(source="user.full_name") + # Core user identity user_id = serializers.ReadOnlyField(source="user.id") - discord_id = serializers.ReadOnlyField(source="user.discord_id") + full_name = serializers.ReadOnlyField(source="user.full_name") muid = serializers.ReadOnlyField(source="user.muid") + discord_id = serializers.ReadOnlyField(source="user.discord_id") email = serializers.ReadOnlyField(source="user.email") mobile = serializers.ReadOnlyField(source="user.mobile") + gender = serializers.ReadOnlyField(source="user.gender") + dob = serializers.ReadOnlyField(source="user.dob") + joined = serializers.ReadOnlyField(source="user.created_at") + + + # Geographic info + district = serializers.SerializerMethodField() + state = serializers.SerializerMethodField() + country = serializers.SerializerMethodField() + + # Role info role_title = serializers.ReadOnlyField(source="role.title") + # Related data + organizations = serializers.SerializerMethodField() + interest_groups = serializers.SerializerMethodField() + + # Role-specific profile + role_profile = serializers.SerializerMethodField() + class Meta: model = UserRoleLink fields = [ "id", "user_id", - "discord_id", - "muid", "full_name", + "muid", + "discord_id", + "email", + "mobile", + "gender", + "dob", + "joined", + "district", + "state", + "country", "verified", "role_id", "role_title", - "email", - "mobile", + "organizations", + "interest_groups", + "role_profile", ] + def get_district(self, obj): + district = obj.user.district + if district: + return {"id": district.id, "name": district.name} + return None + + def get_state(self, obj): + try: + state = obj.user.district.zone.state + return {"id": state.id, "name": state.name} + except AttributeError: + return None + + def get_country(self, obj): + try: + country = obj.user.district.zone.state.country + return {"id": country.id, "name": country.name} + except AttributeError: + return None + + def get_organizations(self, obj): + # Use .all() so Django resolves results from _prefetched_objects_cache. + # Calling .select_related() here would clone the queryset and clear the + # cache, issuing a new SQL query per user (N+1). + result = [] + for link in obj.user.user_organization_link_user.all(): + result.append({ + "org_id": link.org.id if link.org else None, + "org_title": link.org.title if link.org else None, + "org_type": link.org.org_type if link.org else None, + "department": link.department.title if link.department else None, + "graduation_year": link.graduation_year, + "verified": link.verified, + }) + return result + + def get_interest_groups(self, obj): + # Use .all() to stay in _prefetched_objects_cache; .select_related() + # or .values_list() would clone the queryset and bypass the prefetch cache. + return [ + link.ig.name + for link in obj.user.user_ig_link_user.all() + if link.ig + ] + + def get_role_profile(self, obj): + role_title = obj.role.title if obj.role else "" + + # ----- Mentor (all tiers) ----- + if RoleType.MENTOR.value in role_title or "Mentor" in role_title: + # Use next(iter(.all()), None) instead of .first() — .first() adds + # ORDER BY pk + LIMIT 1 and clears _result_cache, bypassing the + # prefetch set up in the view (N+1). + mentor = next(iter(obj.user.user_mentor_user.all()), None) + if mentor: + return { + "type": "mentor", + "mentor_tier": mentor.mentor_tier, + "about": mentor.about, + "expertise": mentor.expertise, + "reason": mentor.reason, + "hours_available": mentor.hours, + "preferred_ig_ids": mentor.preferred_ig_ids, + "status": mentor.status, + "org_id": mentor.org_id, + } + return {"type": "mentor"} + + # ----- Company ----- + if RoleType.COMPANY.value in role_title or "Company" in role_title: + company = getattr(obj.user, "company_profile", None) + if company: + return { + "type": "company", + "company_id": company.id, + "name": company.name, + "slug": company.slug, + "description": company.description, + "industry_sector": company.industry_sector, + "company_size": company.company_size, + "website_link": company.website_link, + "linkedin_url": company.linkedin_url, + "location": company.location, + "legal_name": company.legal_name, + "registration_number": company.registration_number, + "tax_id": company.tax_id, + "status": company.status, + "verification_document_url": company.verification_document_url, + "verification_requested_at": company.verification_requested_at, + "rejection_reason": company.rejection_reason, + "founded_year": company.founded_year, + } + return {"type": "company"} + + # ----- Enabler ----- + if RoleType.ENABLER.value in role_title or "Enabler" in role_title: + # Enablers do not have a dedicated profile table. + # Their key info is surfaced through the expanded user fields above. + return { + "type": "enabler", + "note": "Enabler-specific details are covered by the user fields above.", + } + + # ----- Other / Unknown role ----- + return None + class UserDetailsEditSerializer(serializers.ModelSerializer): organizations = serializers.ListField(write_only=True) roles = serializers.ListField(write_only=True) interest_groups = serializers.ListField(write_only=True) department = serializers.CharField(write_only=True) - graduation_year = serializers.CharField(write_only=True) + graduation_year = serializers.CharField(write_only=True, required=False, allow_null=True) admin = serializers.CharField(write_only=True) class Meta: @@ -293,14 +441,15 @@ def to_representation(self, instance): college := instance.user_organization_link_user.filter( org__org_type=OrganizationType.COLLEGE.value ) + .order_by("-created_at", "-id") .select_related("org__district__zone__state__country", "department") .first() ): data.update( { - "country": getattr(college.district.zone.state.country, "id", None), - "state": getattr(college.district.zone.state, "id", None), - "district": getattr(college.district, "id", None), + "country": getattr(college.org.district.zone.state.country, "id", None) if college.org.district else None, + "state": getattr(college.org.district.zone.state, "id", None) if college.org.district else None, + "district": getattr(college.org.district, "id", None) if college.org.district else None, "department": getattr(college.department, "id", None), "graduation_year": college.graduation_year, } @@ -338,12 +487,16 @@ def update(self, instance, validated_data): id__in=organization_ids ).order_by("org_type") + # Extract these before the loop so all orgs get the correct value + department_id = validated_data.pop("department", None) + graduation_year = validated_data.pop("graduation_year", None) + if ( organizations.exists() and organizations.first().org_type != OrganizationType.COLLEGE.value ): - validated_data.pop("department", None) - validated_data.pop("graduation_year", None) + department_id = None + graduation_year = None UserOrganizationLink.objects.bulk_create( [ @@ -353,8 +506,9 @@ def update(self, instance, validated_data): created_by=admin, created_at=current_time, verified=True, - department_id=validated_data.pop("department", None), - graduation_year=validated_data.pop("graduation_year", None), + department_id=department_id, + graduation_year=graduation_year, + is_alumni=check_alumni_status(graduation_year), ) for org in organizations ] @@ -410,52 +564,89 @@ class UserOrgLinkSerializer(serializers.Serializer): def create(self, validated_data): department = validated_data.get("department", None) graduation_year = validated_data.get("graduation_year", None) - is_alumni = validated_data.get("is_alumni", False) + # Always compute is_alumni from graduation_year; ignore any client-supplied value. + is_alumni = check_alumni_status(graduation_year) is_college = lambda org: org.org_type == OrganizationType.COLLEGE.value user_id = self.context.get("user") - if org := validated_data.get("organization"): - org_link = UserOrganizationLink.objects.create( - user=user_id, - org=org, - created_by=user_id, - created_at=DateTimeUtils.get_current_utc_time(), - verified=True, - department=( - department - if is_college(validated_data.get("organization")) - else None - ), - graduation_year=( - graduation_year - if is_college(validated_data.get("organization")) - else None - ), - is_alumni=( - is_alumni - if is_college(validated_data.get("organization")) - else None - ), - ) - else: - org_link = "skiped_creation" - if validated_data.get("is_student") or ( - validated_data.get("organization") - and is_college(validated_data.get("organization")) - ): - student_role_id = ( - Role.objects.only("id").get(title=RoleType.STUDENT.value).id - ) - if not UserRoleLink.objects.filter( - user=user_id, role_id=student_role_id - ).exists(): - UserRoleLink.objects.create( + with transaction.atomic(): + org = validated_data.get("organization") + if org is None: + return "skiped_creation" + + college_link = is_college(org) + if college_link: + existing_links = list( + UserOrganizationLink.objects.filter( + user=user_id, + org__org_type=OrganizationType.COLLEGE.value, + ).order_by("-created_at", "-id") + ) + + if existing_links: + org_link = existing_links[0] + org_link.org = org + org_link.department = department + org_link.graduation_year = graduation_year + org_link.is_alumni = is_alumni + org_link.verified = True + org_link.created_by = user_id + org_link.save( + update_fields=[ + "org", + "department", + "graduation_year", + "is_alumni", + "verified", + "created_by", + ] + ) + + if len(existing_links) > 1: + UserOrganizationLink.objects.filter( + id__in=[link.id for link in existing_links[1:]] + ).delete() + else: + org_link = UserOrganizationLink.objects.create( + user=user_id, + org=org, + created_by=user_id, + created_at=DateTimeUtils.get_current_utc_time(), + verified=True, + department=department, + graduation_year=graduation_year, + is_alumni=is_alumni, + ) + else: + org_link = UserOrganizationLink.objects.create( user=user_id, - role_id=student_role_id, + org=org, created_by=user_id, created_at=DateTimeUtils.get_current_utc_time(), verified=True, + department=None, + graduation_year=None, + is_alumni=None, ) - return org_link + + if validated_data.get("is_student") or ( + validated_data.get("organization") + and is_college(validated_data.get("organization")) + ): + student_role_id = ( + Role.objects.only("id").get(title=RoleType.STUDENT.value).id + ) + if not UserRoleLink.objects.filter( + user=user_id, role_id=student_role_id + ).exists(): + UserRoleLink.objects.create( + user=user_id, + role_id=student_role_id, + created_by=user_id, + created_at=DateTimeUtils.get_current_utc_time(), + verified=True, + ) + + return org_link class Meta: fields = [ @@ -491,6 +682,7 @@ class UserBasicDetailsSerializer(serializers.ModelSerializer): class Meta: model = User fields = ( + "id", "full_name", "muid", "interest_groups", diff --git a/api/dashboard/user/dash_user_views.py b/api/dashboard/user/dash_user_views.py index e1bfcb99e..b2164ce98 100644 --- a/api/dashboard/user/dash_user_views.py +++ b/api/dashboard/user/dash_user_views.py @@ -15,6 +15,8 @@ from utils.utils import CommonUtils, DateTimeUtils, DiscordWebhooks, send_template_mail from . import dash_user_serializer from django.core.cache import cache +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s BE_DOMAIN_NAME = decouple_config("BE_DOMAIN_NAME") @@ -22,10 +24,14 @@ class UserInfoAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema( + tags=['Dashboard - User'], + description="Retrieve User Info.", + responses={200: dash_user_serializer.UserSerializer}, + ) def get(self, request): user_muid = JWTUtils.fetch_muid(request) - # user = cache.get(f"db_user_{user_muid}") - # if not user: + user_id = JWTUtils.fetch_user_id(request) user = ( User.objects.prefetch_related( "user_domains", "user_endgoals", "user_role_link_user" @@ -33,12 +39,19 @@ def get(self, request): .filter(muid=user_muid) .first() ) - cache.set(f"db_user_{user_muid}", user, timeout=60) + if user is not None: + cache.set(f"db_user_{user_id}", user, timeout=60) if user is None: return CustomResponse( general_message="No user data available" ).get_failure_response() + try: + cache.set(f"db_user_{user_muid}", user, timeout=60) + except Exception: + # Cache is an optimization; user info should still return when Redis is unavailable. + pass + response = dash_user_serializer.UserSerializer(user, many=False).data return CustomResponse(response=response).get_success_response() @@ -48,6 +61,11 @@ class UserGetPatchDeleteAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - User'], + description="Retrieve User Get Patch Delete.", + responses={200: dash_user_serializer.UserDetailsSerializer}, + ) def get(self, request, user_id): user = User.objects.filter(id=user_id).first() @@ -61,16 +79,33 @@ def get(self, request, user_id): return CustomResponse(response=serializer.data).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - User'], description="Delete User Get Patch Delete.", + responses={200: dash_user_serializer.UserDetailsSerializer}, + ) def delete(self, request, user_id): + from django.db.models import ProtectedError + from django.db import IntegrityError try: - user = User.objects.get(id=user_id).delete() + user = User.objects.get(id=user_id) + user.delete() return CustomResponse( general_message="User deleted successfully" ).get_success_response() except User.DoesNotExist as e: return CustomResponse(general_message=str(e)).get_failure_response() + except (ProtectedError, IntegrityError) as e: + return CustomResponse( + general_message="Cannot delete user as they are linked to other data." + ).get_failure_response() + except Exception as e: + return CustomResponse(general_message=str(e)).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - User'], + description="Partially update User Get Patch Delete.", + responses={200: dash_user_serializer.UserDetailsEditSerializer}, + ) def patch(self, request, user_id): user = User.objects.get(id=user_id) @@ -81,12 +116,17 @@ def patch(self, request, user_id): ) if serializer.is_valid(): serializer.save() + cache.delete(f"db_user_{user_id}") + + cache.delete(f"db_user_{user.muid}") DiscordWebhooks.general_updates( WebHookCategory.USER.value, WebHookActions.UPDATE.value, user_id ) + return CustomResponse( - general_message="User Edited Successfully" + general_message="User Edited Successfully", + response=serializer.data, ).get_success_response() return CustomResponse(general_message=serializer.errors).get_failure_response() @@ -95,12 +135,74 @@ def patch(self, request, user_id): class UserAPI(APIView): authentication_classes = [CustomizePermission] + def normalize_list_param(self, values): + """ + Supports: + - repeated params: ?x=a&x=b + - csv params: ?x=a,b + - mixed: ?x=a&x=b,c + """ + result = [] + for value in values: + if value: + result.extend(v.strip() for v in value.split(",") if v.strip()) + return result @role_required([RoleType.ADMIN.value]) + + @extend_schema( + tags=['Dashboard - User'], + description="Retrieve User.", + responses={200: dash_user_serializer.UserDashboardSerializer}, + ) def get(self, request): user_queryset = User.objects.select_related( "wallet_user", "user_lvl_link_user", "user_lvl_link_user__level" ).all() + # New Filters + + # karma filter + minimum_karma = request.query_params.get("minKarma") + if minimum_karma: + try: + minimum_karma = int(minimum_karma) + except ValueError: + return CustomResponse( + general_message="Invalid minKarma. It must be an integer." + ).get_failure_response() + + user_queryset = user_queryset.filter( + wallet_user__karma__gte=minimum_karma + ) + + # level filter + level = request.query_params.get("level") + if level: + user_queryset = user_queryset.filter( + user_lvl_link_user__level__name=level + ) + + # skill filter + skills = self.normalize_list_param(request.query_params.getlist("skillIds")) + if skills: + user_queryset = user_queryset.filter( + skill_progress__skill_id__in=skills + ).distinct() + + # interest group filter + interest_group_ids = self.normalize_list_param(request.query_params.getlist("interestGroupIds")) + if interest_group_ids: + user_queryset = user_queryset.filter( + user_ig_link_user__ig_id__in=interest_group_ids + ).distinct() + + # achievement filter + achievement_ids = self.normalize_list_param(request.query_params.getlist("achievementIds")) + if achievement_ids: + user_queryset = user_queryset.filter( + achievements__achievement_id__in=achievement_ids + ).distinct() + queryset = CommonUtils.get_paginated_queryset( user_queryset, request, @@ -114,6 +216,7 @@ def get(self, request): { "full_name": "full_name", "karma": "wallet_user__karma", + "level": "user_lvl_link_user__level__name", "created_at": "created_at", }, ) @@ -130,6 +233,11 @@ class UserManagementCSV(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - User'], + description="Retrieve User Management C S V.", + responses={200: dash_user_serializer.UserDashboardSerializer}, + ) def get(self, request): user_queryset = User.objects.select_related( "wallet_user", "user_lvl_link_user", "user_lvl_link_user__level" @@ -146,8 +254,27 @@ class UserVerificationAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - User'], + description="Retrieve User Verification.", + responses={200: dash_user_serializer.UserVerificationSerializer}, + ) def get(self, request): - user_queryset = UserRoleLink.objects.select_related("user", "role").filter( + user_queryset = UserRoleLink.objects.select_related( + "user", + "user__district", + "user__district__zone", + "user__district__zone__state", + "user__district__zone__state__country", + "role", + ).prefetch_related( + "user__user_organization_link_user__org", + "user__user_organization_link_user__org__district", + "user__user_organization_link_user__department", + "user__user_ig_link_user__ig", + "user__user_mentor_user", + "user__company_profile", + ).filter( verified=False ) @@ -178,6 +305,11 @@ def get(self, request): ) @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - User'], + description="Partially update User Verification.", + responses={200: dash_user_serializer.UserVerificationSerializer}, + ) def patch(self, request, link_id): user = UserRoleLink.objects.get(id=link_id) @@ -210,6 +342,9 @@ def patch(self, request, link_id): ).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Dashboard - User'], description="Delete User Verification.", + responses={200: dash_user_serializer.UserVerificationSerializer}, + ) def delete(self, request, link_id): link = UserRoleLink.objects.get(id=link_id) link.delete() @@ -223,8 +358,27 @@ class UserVerificationCSV(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Dashboard - User'], + description="Retrieve User Verification C S V.", + responses={200: dash_user_serializer.UserVerificationSerializer}, + ) def get(self, request): - user_queryset = UserRoleLink.objects.select_related("user", "role").filter( + user_queryset = UserRoleLink.objects.select_related( + "user", + "user__district", + "user__district__zone", + "user__district__zone__state", + "user__district__zone__state__country", + "role", + ).prefetch_related( + "user__user_organization_link_user__org", + "user__user_organization_link_user__org__district", + "user__user_organization_link_user__department", + "user__user_ig_link_user__ig", + "user__user_mentor_user", + "user__company_profile", + ).filter( verified=False ) @@ -235,6 +389,9 @@ def get(self, request): class ForgotPasswordAPI(APIView): + @extend_schema(tags=['Dashboard - User'], description="Create Forgot Password.", + responses={200: OpenApiResponse(description="Forgot-password email sent")}, + ) def post(self, request): email_muid = request.data.get("emailOrMuid") @@ -273,6 +430,12 @@ def post(self, request): class ResetPasswordVerifyTokenAPI(APIView): + @extend_schema(tags=['Dashboard - User'], description="Create Reset Password Verify Token.", + responses={200: inline_serializer( + name='UserResetPasswordVerifyTokenResponse', + fields={'muid': s.CharField()}, + )}, + ) def post(self, request, token): if not (forget_user := ForgotPassword.objects.filter(id=token).first()): return CustomResponse( @@ -292,6 +455,9 @@ def post(self, request, token): class ResetPasswordConfirmAPI(APIView): + @extend_schema(tags=['Dashboard - User'], description="Create Reset Password Confirm.", + responses={200: OpenApiResponse(description="Password reset confirmation")}, + ) def post(self, request, token): if not (forget_user := ForgotPassword.objects.filter(id=token).first()): return CustomResponse( @@ -336,6 +502,9 @@ class UserProfilePictureView(APIView): # general_message="No Profile picture available" # ).get_failure_response() + @extend_schema(tags=['Dashboard - User'], description="Partially update User Profile Picture.", + responses={200: dash_user_serializer.UserSerializer}, + ) def patch(self, request): DiscordWebhooks.general_updates( WebHookCategory.USER_PROFILE.value, @@ -346,6 +515,9 @@ def patch(self, request): general_message="Successfully updated" ).get_success_response() + @extend_schema(tags=['Dashboard - User'], description="Create User Profile Picture.", + responses={200: dash_user_serializer.UserSerializer}, + ) def post(self, request): user_id = request.data.get("user_id") user = User.objects.filter(id=user_id).first() @@ -382,10 +554,15 @@ def post(self, request): class UserAddOrgAPI(APIView): + @extend_schema( + tags=['Dashboard - User'], + description="Create User Add Org.", + request=dash_user_serializer.UserOrgLinkSerializer, + responses={200: dash_user_serializer.UserOrgLinkSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) - if not (user := cache.get(f"db_user_{user_id}")): - user = User.objects.filter(id=user_id).first() + user = User.objects.filter(id=user_id).first() if user is None: return CustomResponse( general_message="No user data available" @@ -394,12 +571,33 @@ def post(self, request): data=request.data, context={"user": user} ) if serializer.is_valid(): - serializer.save() + org_link = serializer.save() + if not org_link or isinstance(org_link, str): + return CustomResponse( + general_message="organisation linked successfully" + ).get_success_response() + + try: + cache.delete(f"db_user_{user_id}") + cache.delete(f"db_user_{user.muid}") + except Exception: + pass + return CustomResponse( - general_message="organisation linked successfully" + general_message="organisation linked successfully", + response={ + "organization_link": dash_user_serializer.GetUserLinkSerializer( + org_link + ).data, + }, ).get_success_response() return CustomResponse(response=serializer.errors).get_failure_response() + @extend_schema( + tags=['Dashboard - User'], + description="Retrieve User Add Org.", + responses={200: dash_user_serializer.GetUserLinkSerializer}, + ) def get(self, request): user = User.objects.filter(id=JWTUtils.fetch_user_id(request)).first() if user is None: @@ -409,7 +607,7 @@ def get(self, request): links = UserOrganizationLink.objects.filter( user=user, org__org_type=OrganizationType.COLLEGE.value - ).select_related("org", "department") + ).order_by("-created_at", "-id").select_related("org", "department")[:1] serializer = dash_user_serializer.GetUserLinkSerializer( instance=links, many=True ) @@ -417,6 +615,11 @@ def get(self, request): class UserSearchAPI(APIView): + @extend_schema( + tags=['Dashboard - User'], + description="Retrieve User Search.", + responses={200: dash_user_serializer.UserBasicDetailsSerializer}, + ) def get(self, request): role = request.query_params.get("role") ig_id = request.query_params.get("ig_id") @@ -464,6 +667,9 @@ def get(self, request): class UserPreferencesAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Dashboard - User'], description="Retrieve User Preferences.", + responses={200: dash_user_serializer.UserSerializer}, + ) def get(self, request): user = User.objects.get(id=JWTUtils.fetch_user_id(request)) @@ -474,6 +680,9 @@ def get(self, request): return CustomResponse(response=response).get_success_response() + @extend_schema(tags=['Dashboard - User'], description="Partially update User Preferences.", + responses={200: dash_user_serializer.UserSerializer}, + ) def patch(self, request): user = User.objects.get(id=JWTUtils.fetch_user_id(request)) diff --git a/api/dashboard/zonal/dash_zonal_helper.py b/api/dashboard/zonal/dash_zonal_helper.py index 2b9ecd37b..fbf4f7d26 100644 --- a/api/dashboard/zonal/dash_zonal_helper.py +++ b/api/dashboard/zonal/dash_zonal_helper.py @@ -5,4 +5,4 @@ def get_user_college_link(user_id): return UserOrganizationLink.objects.filter( user_id=user_id, org__org_type=OrganizationType.COLLEGE.value - ).first() + ).order_by("-created_at", "-id").first() diff --git a/api/dashboard/zonal/dash_zonal_serializer.py b/api/dashboard/zonal/dash_zonal_serializer.py index 323418ad7..a37e39431 100644 --- a/api/dashboard/zonal/dash_zonal_serializer.py +++ b/api/dashboard/zonal/dash_zonal_serializer.py @@ -55,13 +55,13 @@ def get_rank(self, obj): def get_karma(self, obj): return UserOrganizationLink.objects.filter( - org_org_type=OrganizationType.COLLEGE.value, + org__org_type=OrganizationType.COLLEGE.value, org__district__zone=obj.org.district.zone, ).aggregate(total_karma=Sum("user__wallet_user__karma"))["total_karma"] def get_total_members(self, obj): return UserOrganizationLink.objects.filter( - org_org_type=OrganizationType.COLLEGE.value, + org__org_type=OrganizationType.COLLEGE.value, org__district__zone=obj.org.district.zone, ).count() @@ -108,7 +108,7 @@ def get_students_count(self, obj): zone = self.context.get("zone") return ( User.objects.filter( - user__user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, + user_organization_link_user__org__org_type=OrganizationType.COLLEGE.value, user_organization_link_user__org__district__zone=zone, user_lvl_link_user__level=obj, ) diff --git a/api/dashboard/zonal/dash_zonal_views.py b/api/dashboard/zonal/dash_zonal_views.py index 6f22a981a..bf0946bd0 100644 --- a/api/dashboard/zonal/dash_zonal_views.py +++ b/api/dashboard/zonal/dash_zonal_views.py @@ -11,17 +11,28 @@ from utils.types import OrganizationType, RoleType from utils.utils import CommonUtils from . import dash_zonal_helper, dash_zonal_serializer +from drf_spectacular.utils import extend_schema class ZonalDetailsAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ZONAL_CAMPUS_LEAD.value]) + @extend_schema( + tags=['Dashboard - Zonal'], + description="Retrieve Zonal Details.", + responses={200: dash_zonal_serializer.ZonalDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = dash_zonal_helper.get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + serializer = dash_zonal_serializer.ZonalDetailsSerializer( user_org_link, many=False ) @@ -32,11 +43,21 @@ class ZonalTopThreeDistrictAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ZONAL_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - Zonal'], + description="Retrieve Zonal Top Three District.", + responses={200: dash_zonal_serializer.ZonalTopThreeDistrictSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = dash_zonal_helper.get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + district_karma_dict = ( UserOrganizationLink.objects.filter( org__org_type=OrganizationType.COLLEGE.value, @@ -73,11 +94,21 @@ class ZonalStudentLevelStatusAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ZONAL_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - Zonal'], + description="Retrieve Zonal Student Level Status.", + responses={200: dash_zonal_serializer.ZonalStudentLevelStatusSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = dash_zonal_helper.get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + zone = user_org_link.org.district.zone levels = Level.objects.all() @@ -91,11 +122,21 @@ class ZonalStudentDetailsAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ZONAL_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - Zonal'], + description="Retrieve Zonal Student Details.", + responses={200: dash_zonal_serializer.ZonalStudentDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = dash_zonal_helper.get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + rank = ( Wallet.objects.filter( user__user_organization_link_user__org__district__zone=user_org_link.org.district.zone, @@ -152,11 +193,21 @@ class ZonalStudentDetailsCSVAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ZONAL_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - Zonal'], + description="Retrieve Zonal Student Details C S V.", + responses={200: dash_zonal_serializer.ZonalStudentDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = dash_zonal_helper.get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + rank = ( Wallet.objects.filter( user__user_organization_link_user__org__district__zone=user_org_link.org.district.zone, @@ -195,11 +246,21 @@ class ZonalCollegeDetailsAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ZONAL_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - Zonal'], + description="Retrieve Zonal College Details.", + responses={200: dash_zonal_serializer.ZonalCollegeDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = dash_zonal_helper.get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + organizations = ( Organization.objects.filter( district__zone=user_org_link.org.district.zone, @@ -259,11 +320,21 @@ class ZonalCollegeDetailsCSVAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ZONAL_CAMPUS_LEAD.value, ]) + @extend_schema( + tags=['Dashboard - Zonal'], + description="Retrieve Zonal College Details C S V.", + responses={200: dash_zonal_serializer.ZonalCollegeDetailsSerializer}, + ) def get(self, request): user_id = JWTUtils.fetch_user_id(request) user_org_link = dash_zonal_helper.get_user_college_link(user_id) + if user_org_link is None: + return CustomResponse( + general_message="No college organization linked to this user." + ).get_failure_response() + organizations = ( Organization.objects.filter( district__zone=user_org_link.org.district.zone, diff --git a/api/donate/donate_serializer.py b/api/donate/donate_serializer.py index c9d9d5fbc..786f483eb 100644 --- a/api/donate/donate_serializer.py +++ b/api/donate/donate_serializer.py @@ -4,17 +4,89 @@ from django.conf import settings from db.donor import Donor +from db.donation import Donation from utils.utils import DateTimeUtils class DonorSerializer(serializers.ModelSerializer): - currency = serializers.CharField(allow_null=True, allow_blank=True, default='INR') + """Serializer for Donor model - personal info only""" + company = serializers.CharField(allow_null=True, allow_blank=True, required=False) + phone_number = serializers.CharField(allow_null=True, allow_blank=True, required=False) + pan_number = serializers.CharField(allow_null=True, allow_blank=True, required=False) + address = serializers.CharField(allow_null=True, allow_blank=True, required=False) + is_organisation = serializers.BooleanField(default=False) class Meta: model = Donor - exclude = ['created_by', 'created_at', 'id', 'payment_id', 'payment_method'] + exclude = ['created_by', 'created_at', 'id'] def create(self, validated_data): validated_data["created_by_id"] = settings.SYSTEM_ADMIN_ID validated_data["id"] = uuid.uuid4() return Donor.objects.create(**validated_data) + + +class DonationSerializer(serializers.ModelSerializer): + """Serializer for Donation model - payment tracking""" + donation_type = serializers.ChoiceField(choices=['one-time', 'monthly', 'yearly']) + + class Meta: + model = Donation + exclude = ['id', 'created_at'] + + def create(self, validated_data): + validated_data["id"] = uuid.uuid4() + return Donation.objects.create(**validated_data) + + +class SubscriptionSerializer(serializers.Serializer): + """Serializer for subscription creation requests""" + amount = serializers.DecimalField(max_digits=12, decimal_places=2) + currency = serializers.CharField(default='INR') + name = serializers.CharField(max_length=255) + donation_name = serializers.CharField(max_length=100, required=False, allow_blank=True) + email = serializers.EmailField() + phone_number = serializers.CharField(max_length=20, required=False, allow_blank=True) + pan_number = serializers.CharField(max_length=10, required=False, allow_blank=True) + address = serializers.CharField(required=False, allow_blank=True) + company = serializers.CharField(max_length=255, required=False, allow_blank=True) + donation_type = serializers.ChoiceField(choices=['one-time', 'monthly', 'yearly']) + is_organisation = serializers.BooleanField(default=False) + + +class OrderSerializer(serializers.Serializer): + """Serializer for one-time order creation requests""" + amount = serializers.DecimalField(max_digits=12, decimal_places=2) + currency = serializers.CharField(default='INR') + name = serializers.CharField(max_length=255) + donation_name = serializers.CharField(max_length=100, required=False, allow_blank=True) + email = serializers.EmailField() + phone_number = serializers.CharField(max_length=20, required=False, allow_blank=True) + pan_number = serializers.CharField(max_length=10, required=False, allow_blank=True) + address = serializers.CharField(required=False, allow_blank=True) + company = serializers.CharField(max_length=255, required=False, allow_blank=True) + donation_type = serializers.ChoiceField(choices=['one-time', 'monthly', 'yearly'], default='one-time') + is_organisation = serializers.BooleanField(default=False) + + +class BankTransferSerializer(serializers.Serializer): + """Serializer for bank transfer donation requests (amount >= 5L)""" + amount = serializers.DecimalField(max_digits=12, decimal_places=2) + name = serializers.CharField(max_length=255) + donation_name = serializers.CharField(max_length=100, required=False, allow_blank=True) + email = serializers.EmailField() + phone_number = serializers.CharField(max_length=20, required=False, allow_blank=True) + pan_number = serializers.CharField(max_length=10, required=False, allow_blank=True) + address = serializers.CharField(required=False, allow_blank=True) + company = serializers.CharField(max_length=255, required=False, allow_blank=True) + donation_type = serializers.ChoiceField(choices=['one-time', 'monthly', 'yearly'], default='one-time') + is_organisation = serializers.BooleanField(default=False) + proof_url = serializers.URLField(max_length=2000, required=False, allow_blank=True) + reference_code = serializers.CharField(max_length=50) + + def validate_amount(self, value): + """Ensure amount is >= 5,00,000""" + if value < 500000: + raise serializers.ValidationError("Bank transfer is only available for amounts >= ₹5,00,000") + return value + diff --git a/api/donate/urls.py b/api/donate/urls.py index 1a2961cb5..888011af1 100644 --- a/api/donate/urls.py +++ b/api/donate/urls.py @@ -1,7 +1,21 @@ -from . import views from django.urls import path +from .views import ( + RazorPayOrderAPI, + RazorPayVerification, + RazorPaySubscriptionAPI, + RazorPaySubscriptionVerification, + BankTransferAPI +) urlpatterns = [ - path('order/', views.RazorPayOrderAPI.as_view()), - path('verify/', views.RazorPayVerification.as_view()) + # One-time payments + path('order/', RazorPayOrderAPI.as_view(), name='donate-order'), + path('verify/', RazorPayVerification.as_view(), name='donate-verify'), + + # Recurring payments (subscriptions) + path('subscription/create/', RazorPaySubscriptionAPI.as_view(), name='donate-subscription-create'), + path('subscription/verify/', RazorPaySubscriptionVerification.as_view(), name='donate-subscription-verify'), + + # Bank transfer (for donations >= 5L) + path('bank-transfer/', BankTransferAPI.as_view(), name='donate-bank-transfer'), ] \ No newline at end of file diff --git a/api/donate/views.py b/api/donate/views.py index 57ee2bcc3..8ffddecd0 100644 --- a/api/donate/views.py +++ b/api/donate/views.py @@ -1,95 +1,470 @@ import razorpay +import uuid +import base64 from io import BytesIO +from datetime import datetime from django.http import HttpResponse from rest_framework.views import APIView from utils.response import CustomResponse -from .donate_serializer import DonorSerializer +from utils.utils import send_template_mail +from .donate_serializer import DonorSerializer, DonationSerializer, SubscriptionSerializer, OrderSerializer, BankTransferSerializer +from db.donor import Donor +from db.donation import Donation from mulearnbackend.settings import RAZORPAY_ID, RAZORPAY_SECRET -from reportlab.lib.pagesizes import letter +from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib import colors from reportlab.lib.styles import ParagraphStyle from reportlab.platypus import Paragraph, Table, TableStyle +from reportlab.lib.units import mm +from drf_spectacular.utils import extend_schema razorpay_client = razorpay.Client(auth=(RAZORPAY_ID, RAZORPAY_SECRET)) -def create_receipt(transaction_details): - buffer = BytesIO() - c = canvas.Canvas(buffer, pagesize=letter) - width, height = letter - - # Title - title_style = ParagraphStyle( - name="TitleStyle", - fontName="Helvetica-Bold", - fontSize=20, - alignment=1, - spaceAfter=20 - ) - title_text = "Payment Receipt" - title = Paragraph(title_text, title_style) - title_width, title_height = title.wrap(width, height) - title.drawOn(c, (width - title_width) / 2, height - title_height - 30) - - data = [(key, value) for key, value in transaction_details.items()] - table = Table(data, colWidths=(200, 300)) - table.setStyle(TableStyle([ - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('FONTNAME', (0, 0), (-1, -1), 'Helvetica'), - ('FONTSIZE', (0, 0), (-1, -1), 12), - ('BOTTOMPADDING', (0, 0), (-1, -1), 6), - ('GRID', (0, 0), (-1, -1), 1, colors.black) - ])) - table.wrapOn(c, width, height) - table.drawOn(c, 50, height - 220) - - footer_text = "Thank you for Donation!" - c.setFont("Helvetica", 12) - c.drawCentredString(width / 2, 50, footer_text) +def number_to_words(num): + """Convert a number to words (Indian numbering system)""" + ones = ['', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', + 'Ten', 'Eleven', 'Twelve', 'Thirteen', 'Fourteen', 'Fifteen', 'Sixteen', + 'Seventeen', 'Eighteen', 'Nineteen'] + tens = ['', '', 'Twenty', 'Thirty', 'Forty', 'Fifty', 'Sixty', 'Seventy', 'Eighty', 'Ninety'] + + if num == 0: + return 'Zero' + + def convert_less_than_thousand(n): + if n < 20: + return ones[n] + elif n < 100: + return tens[n // 10] + (' ' + ones[n % 10] if n % 10 else '') + else: + return ones[n // 100] + ' Hundred' + (' ' + convert_less_than_thousand(n % 100) if n % 100 else '') + + def convert_indian(n): + if n < 1000: + return convert_less_than_thousand(n) + elif n < 100000: + return convert_indian(n // 1000) + ' Thousand' + (' ' + convert_less_than_thousand(n % 1000) if n % 1000 else '') + elif n < 10000000: + return convert_indian(n // 100000) + ' Lakh' + (' ' + convert_indian(n % 100000) if n % 100000 else '') + else: + return convert_indian(n // 10000000) + ' Crore' + (' ' + convert_indian(n % 10000000) if n % 10000000 else '') + + # Handle decimal part + num = float(num) + integer_part = int(num) + decimal_part = int(round((num - integer_part) * 100)) + + result = convert_indian(integer_part) + if decimal_part: + result += ' and ' + convert_indian(decimal_part) + ' Paise' + + return result + ' Only' + + +def generate_donation_invoice(invoice_data): + """ + Generate a professional donation invoice PDF. + + Args: + invoice_data: dict with keys: + - invoice_number: str + - date: str + - donor_name: str + - donor_email: str + - donor_address: str (optional) + - donor_pan: str (optional) + - donor_phone: str (optional) + - company: str (optional) + - amount: float + - currency: str + - payment_id: str + - donation_type: str + - remarks: str (optional) + + Returns: + bytes: PDF content + """ + buffer = BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 + + # Margins + left_margin = 30 + right_margin = width - 30 + top_margin = height - 30 + + # Colors + header_color = colors.HexColor('#1a1a1a') + border_color = colors.HexColor('#000000') + + # ========== TITLE ========== + c.setFont("Helvetica-Bold", 16) + c.drawCentredString(width / 2, top_margin, "INVOICE") + + y = top_margin - 30 + + # ========== HEADER SECTION ========== + # Left box - Organization details + c.setStrokeColor(border_color) + c.setLineWidth(1) + c.rect(left_margin, y - 80, 270, 80) + + c.setFont("Helvetica-Bold", 10) + c.drawString(left_margin + 5, y - 15, "MULEARN FOUNDATION") + c.setFont("Helvetica", 8) + c.drawString(left_margin + 5, y - 28, "NILA BUILDING, PHASE-1, TECHNOPARK") + c.drawString(left_margin + 5, y - 40, "KARYAVATTOM, THIRUVANANTHAPURAM") + c.setFont("Helvetica", 8) + c.setFillColor(colors.blue) + c.drawString(left_margin + 5, y - 52, "E-Mail: mulearnadmin@gtechindia.org") + c.setFillColor(colors.black) + + # Right boxes - Invoice details + box_start_x = 300 + box_width = (right_margin - box_start_x) / 2 + + # Invoice No and Date row + c.rect(box_start_x, y - 35, box_width, 35) + c.rect(box_start_x + box_width, y - 35, box_width, 35) + + c.setFont("Helvetica", 8) + c.drawString(box_start_x + 5, y - 12, "Invoice No.") + c.setFont("Helvetica-Bold", 9) + c.drawString(box_start_x + 5, y - 28, str(invoice_data.get('invoice_number', 'N/A'))) + + c.setFont("Helvetica", 8) + c.drawString(box_start_x + box_width + 5, y - 12, "Dated") + c.setFont("Helvetica-Bold", 9) + c.drawString(box_start_x + box_width + 5, y - 28, invoice_data.get('date', datetime.now().strftime('%d-%b-%y'))) + + # Delivery Note and Mode of Payment row + c.rect(box_start_x, y - 60, box_width, 25) + c.rect(box_start_x + box_width, y - 60, box_width, 25) + + c.setFont("Helvetica", 8) + c.drawString(box_start_x + 5, y - 47, "Delivery Note") + c.drawString(box_start_x + box_width + 5, y - 47, "Mode/Terms of Payment") + c.setFont("Helvetica", 8) + c.drawString(box_start_x + box_width + 5, y - 57, "Online Payment") + + # Reference row + c.rect(box_start_x, y - 80, box_width, 20) + c.rect(box_start_x + box_width, y - 80, box_width, 20) + + c.setFont("Helvetica", 8) + c.drawString(box_start_x + 5, y - 72, "Reference No. & Date.") + c.drawString(box_start_x + box_width + 5, y - 72, "Other References") + + y = y - 80 + + # ========== CONSIGNEE / BUYER SECTION ========== + consignee_height = 70 + + # Consignee box + c.rect(left_margin, y - consignee_height, 270, consignee_height) + c.setFont("Helvetica", 8) + c.drawString(left_margin + 5, y - 12, "Consignee (Ship to)") + c.setFont("Helvetica-Bold", 9) + + donor_name = invoice_data.get('donor_name', 'N/A') + company = invoice_data.get('company', '') + if company: + c.drawString(left_margin + 5, y - 25, company) + c.setFont("Helvetica", 8) + c.drawString(left_margin + 5, y - 37, f"Contact: {donor_name}") + else: + c.drawString(left_margin + 5, y - 25, donor_name) + + donor_address = invoice_data.get('donor_address', '') + if donor_address: + # Word wrap address + address_lines = [donor_address[i:i+40] for i in range(0, len(donor_address), 40)] + y_offset = 37 if company else 37 + for i, line in enumerate(address_lines[:2]): + c.setFont("Helvetica", 8) + c.drawString(left_margin + 5, y - y_offset - (i * 12), line) + + # Right side order details + c.rect(box_start_x, y - 25, box_width, 25) + c.rect(box_start_x + box_width, y - 25, box_width, 25) + c.setFont("Helvetica", 8) + c.drawString(box_start_x + 5, y - 12, "Buyer's Order No.") + c.drawString(box_start_x + box_width + 5, y - 12, "Dated") + + c.rect(box_start_x, y - 50, box_width, 25) + c.rect(box_start_x + box_width, y - 50, box_width, 25) + c.drawString(box_start_x + 5, y - 37, "Dispatch Doc No.") + c.drawString(box_start_x + box_width + 5, y - 37, "Delivery Note Date") + + c.rect(box_start_x, y - consignee_height, box_width, 20) + c.rect(box_start_x + box_width, y - consignee_height, box_width, 20) + c.drawString(box_start_x + 5, y - 62, "Dispatched through") + c.drawString(box_start_x + box_width + 5, y - 62, "Destination") + + y = y - consignee_height + + # ========== BUYER (Bill to) SECTION ========== + buyer_height = 60 + c.rect(left_margin, y - buyer_height, 270, buyer_height) + c.rect(box_start_x, y - buyer_height, right_margin - box_start_x, buyer_height) + + c.setFont("Helvetica", 8) + c.drawString(left_margin + 5, y - 12, "Buyer (Bill to)") + c.setFont("Helvetica-Bold", 9) + + if company: + c.drawString(left_margin + 5, y - 25, company) + c.setFont("Helvetica", 8) + c.drawString(left_margin + 5, y - 37, f"Contact: {donor_name}") + else: + c.drawString(left_margin + 5, y - 25, donor_name) + + if donor_address: + y_offset = 49 if company else 37 + for i, line in enumerate(address_lines[:2]): + c.setFont("Helvetica", 8) + c.drawString(left_margin + 5, y - y_offset - (i * 12), line) + + c.setFont("Helvetica", 8) + c.drawString(box_start_x + 5, y - 12, "Terms of Delivery") + + y = y - buyer_height + + # ========== ITEMS TABLE ========== + table_header_height = 25 + item_row_height = 120 + + # Calculate column widths to span full width (right_margin - left_margin = 535) + total_width = right_margin - left_margin + col_widths = [35, 245, 65, 65, 45, 80] # Total = 535 + headers = ['Sl\nNo.', 'Particulars', 'Quantity', 'Rate', 'per', 'Amount'] + + c.setLineWidth(1) + x = left_margin + for i, (header, col_width) in enumerate(zip(headers, col_widths)): + c.rect(x, y - table_header_height, col_width, table_header_height) + c.setFont("Helvetica-Bold", 9) + lines = header.split('\n') + for j, line in enumerate(lines): + c.drawCentredString(x + col_width / 2, y - 10 - (j * 10), line) + x += col_width + + y = y - table_header_height + + # Item row + x = left_margin + for col_width in col_widths: + c.rect(x, y - item_row_height, col_width, item_row_height) + x += col_width + + # Item content + c.setFont("Helvetica", 10) + c.drawCentredString(left_margin + col_widths[0] / 2, y - 20, "1") + + donation_type = invoice_data.get('donation_type', 'Donation') + c.setFont("Helvetica-Bold", 10) + c.drawString(left_margin + col_widths[0] + 10, y - 20, f"Sponsorship-Mulearn") + c.setFont("Helvetica", 9) + c.drawString(left_margin + col_widths[0] + 10, y - 35, f"({donation_type})") + + amount = float(invoice_data.get('amount', 0)) + c.setFont("Helvetica", 10) + c.drawRightString(right_margin - 10, y - 20, f"{amount:,.2f}") + + y = y - item_row_height + + # Total row + total_row_height = 25 + x = left_margin + for i, col_width in enumerate(col_widths): + c.rect(x, y - total_row_height, col_width, total_row_height) + x += col_width + + c.setFont("Helvetica-Bold", 10) + c.drawRightString(right_margin - col_widths[5] - 10, y - 17, "Total") + c.setFillColor(colors.HexColor('#0000FF')) + c.drawRightString(right_margin - 10, y - 17, f"₹ {amount:,.2f}") + c.setFillColor(colors.black) + + # E. & O.E. + c.setFont("Helvetica-Oblique", 8) + c.drawRightString(right_margin - 5, y - total_row_height - 10, "E. & O.E") + + y = y - total_row_height + + # ========== AMOUNT IN WORDS ========== + amount_words_height = 30 + c.rect(left_margin, y - amount_words_height, right_margin - left_margin, amount_words_height) + + c.setFont("Helvetica-Bold", 9) + c.setFillColor(colors.HexColor('#0000FF')) + c.drawString(left_margin + 5, y - 12, "Amount Chargeable (in words)") + c.setFillColor(colors.black) + c.setFont("Helvetica-Bold", 10) + currency = invoice_data.get('currency', 'INR') + amount_in_words = number_to_words(amount) + c.drawString(left_margin + 5, y - 25, f"{currency} {amount_in_words}") + + y = y - amount_words_height + + # ========== REMARKS AND BANK DETAILS ========== + remarks_height = 100 + c.rect(left_margin, y - remarks_height, 200, remarks_height) + c.rect(left_margin + 200, y - remarks_height, right_margin - left_margin - 200, remarks_height) + + # Remarks + c.setFont("Helvetica-Oblique", 8) + c.drawString(left_margin + 5, y - 12, "Remarks:") + c.setFont("Helvetica", 9) + remarks = invoice_data.get('remarks', 'Sponsorship for Mulearn') + c.drawString(left_margin + 5, y - 25, remarks) + + # Bank Details + bank_x = left_margin + 210 + c.setFont("Helvetica-Bold", 9) + c.drawCentredString(bank_x + 80, y - 15, "Company's Bank Details") + + c.setFont("Helvetica", 8) + details = [ + ("A/c Holder's Name :", "MULEARN FOUNDATION"), + ("Bank Name :", "ICICI Bank"), + ("A/c No. :", "263405011014"), + ("Branch & IFS Code:", "Technopark, Trivandrum & ICIC0002534"), + ] + + for i, (label, value) in enumerate(details): + c.setFont("Helvetica", 8) + c.drawString(bank_x + 5, y - 30 - (i * 12), label) + c.setFont("Helvetica-Bold", 8) + if "ICIC" in value or "MULEARN" in value: + c.setFillColor(colors.HexColor('#8B0000')) + c.drawString(bank_x + 90, y - 30 - (i * 12), value) + c.setFillColor(colors.black) + + # for MULEARN FOUNDATION + c.setFont("Helvetica-Bold", 9) + c.setFillColor(colors.HexColor('#8B0000')) + c.drawRightString(right_margin - 10, y - 75, "for MULEARN FOUNDATION") + c.setFillColor(colors.black) + + y = y - remarks_height + + # ========== SIGNATURE ========== + signature_height = 30 + c.rect(left_margin, y - signature_height, right_margin - left_margin, signature_height) + c.setFont("Helvetica-Oblique", 9) + c.drawRightString(right_margin - 10, y - 20, "Authorised Signatory") + + y = y - signature_height + + # ========== FOOTER ========== + c.setFillColor(colors.HexColor('#8B0000')) + c.setFont("Helvetica", 9) + c.drawCentredString(width / 2, y - 20, "This is a Computer Generated Invoice") + c.setFillColor(colors.black) + c.save() - pdf = buffer.getvalue() + pdf_content = buffer.getvalue() buffer.close() + + return pdf_content + + +def generate_invoice_number(): + """Generate a unique invoice number based on timestamp""" + now = datetime.now() + return f"MUL{now.strftime('%Y%m%d%H%M%S')}" + +def create_receipt(transaction_details): + """Legacy function - redirects to new invoice generator""" + invoice_data = { + 'invoice_number': generate_invoice_number(), + 'date': datetime.now().strftime('%d-%b-%y'), + 'donor_name': transaction_details.get('Name', 'N/A'), + 'donor_email': transaction_details.get('Email', ''), + 'amount': transaction_details.get('Amount', 0), + 'currency': transaction_details.get('Currency', 'INR'), + 'payment_id': transaction_details.get('payment_id', ''), + 'donation_type': 'One-time Donation', + } + + pdf_content = generate_donation_invoice(invoice_data) + response = HttpResponse(content_type="application/pdf") - response["Content-Disposition"] = 'attachment; filename="razorpay_receipt.pdf"' - response.write(pdf) + response["Content-Disposition"] = 'attachment; filename="mulearn_donation_receipt.pdf"' + response.write(pdf_content) return response +def get_or_create_donor(email, donor_data): + """ + Get existing donor by email or create a new one. + Returns the Donor instance. + """ + try: + donor = Donor.objects.get(email=email) + # Update donor info if changed + for field in ['name', 'phone_number', 'pan_number', 'address', 'company', 'is_organisation']: + if field in donor_data and donor_data[field]: + setattr(donor, field, donor_data[field]) + donor.save() + return donor + except Donor.DoesNotExist: + serializer = DonorSerializer(data=donor_data) + if serializer.is_valid(): + return serializer.save() + raise ValueError(f"Invalid donor data: {serializer.errors}") + class RazorPayOrderAPI(APIView): + @extend_schema( + tags=['Donate'], + description="Create Razor Pay Order.", + request=OrderSerializer, + responses={200: OrderSerializer}, + ) def post(self, request): try: - serializer = DonorSerializer(data=request.data) + serializer = OrderSerializer(data=request.data) if not serializer.is_valid(): - return CustomResponse(general_message=serializer.errors).get_failure_response() + return CustomResponse(message=serializer.errors).get_failure_response() validated_data = serializer.validated_data data = { "amount": int(float(validated_data.get("amount")) * 100), - "currency": validated_data.get("currency"), + "currency": validated_data.get("currency", "INR"), "payment_capture": 1, "notes": { "email": validated_data.get("email"), "name": validated_data.get("name"), - "phone_number": validated_data.get("phone_number", None), - "company": validated_data.get("company", None), - "pan_number": validated_data.get("pan_number", None), - "validated_data": validated_data + "phone_number": validated_data.get("phone_number", ""), + "company": validated_data.get("company", ""), + "pan_number": validated_data.get("pan_number", ""), + "address": validated_data.get("address", ""), + "donation_type": validated_data.get("donation_type", "one-time"), + "is_organisation": str(validated_data.get("is_organisation", False)), + "amount": str(float(validated_data.get("amount"))), }, } order = razorpay_client.order.create(data) return CustomResponse(response=order).get_success_response() except razorpay.errors.BadRequestError as e: - return CustomResponse(message=str(e)).get_failure_response() + return CustomResponse(general_message=str(e)).get_failure_response() class RazorPayVerification(APIView): + @extend_schema( + tags=['Donate'], + description="Create Razor Pay Verification.", + request=DonationSerializer, + responses={200: DonationSerializer}, + ) def post(self, request): try: razorpay_client.utility.verify_payment_signature( @@ -99,28 +474,434 @@ def post(self, request): "razorpay_signature": request.data.get("razorpay_signature"), } ) - data = razorpay_client.payment.fetch( + + # Fetch payment details from Razorpay + payment_data = razorpay_client.payment.fetch( request.data.get("razorpay_payment_id") ) + + notes = payment_data.get('notes', {}) + order_id = request.data.get("razorpay_order_id") + payment_id = request.data.get("razorpay_payment_id") + + # Prepare donor data + donor_data = { + 'name': notes.get('name', ''), + 'email': notes.get('email', ''), + 'phone_number': notes.get('phone_number', ''), + 'pan_number': notes.get('pan_number', ''), + 'address': notes.get('address', ''), + 'company': notes.get('company', ''), + 'is_organisation': notes.get('is_organisation', 'False') == 'True', + } + + # Get or create donor + donor = get_or_create_donor(donor_data['email'], donor_data) + + # Create donation record + donation_data = { + 'donor': donor.id, + 'order_id': order_id, + 'payment_id': payment_id, + 'payment_method': payment_data.get('method', ''), + 'amount': float(payment_data['amount']) / 100, + 'currency': payment_data.get('currency', 'INR'), + 'donation_type': notes.get('donation_type', 'one-time'), + 'is_paid': True, + } + + donation_serializer = DonationSerializer(data=donation_data) + if donation_serializer.is_valid(): + donation_serializer.save() + + # Build transaction details for response + transaction_details = { + "Amount": float(payment_data['amount']) / 100, + "Currency": payment_data.get('currency', 'INR'), + "payment_id": payment_id, + "Payment_method": payment_data.get('method', ''), + "Name": notes.get('name', ''), + "Email": notes.get('email', ''), + } + if company := notes.get('company'): + transaction_details["Company"] = company + if phone_number := notes.get('phone_number'): + transaction_details["Phone Number"] = phone_number + if pan_number := notes.get('pan_number'): + transaction_details["PAN number"] = pan_number + if address := notes.get('address'): + transaction_details["Address"] = address + + # Generate invoice PDF + donation_type = notes.get('donation_type', 'one-time') + invoice_number = generate_invoice_number() + invoice_data = { + 'invoice_number': invoice_number, + 'date': datetime.now().strftime('%d-%b-%y'), + 'donor_name': notes.get('name', ''), + 'donor_email': notes.get('email', ''), + 'donor_address': notes.get('address', ''), + 'donor_pan': notes.get('pan_number', ''), + 'donor_phone': notes.get('phone_number', ''), + 'company': notes.get('company', ''), + 'amount': float(payment_data['amount']) / 100, + 'currency': payment_data.get('currency', 'INR'), + 'payment_id': payment_id, + 'donation_type': donation_type.replace('-', ' ').title() + ' Donation', + 'remarks': 'Sponsorship for Mulearn', + } + + pdf_content = generate_donation_invoice(invoice_data) + pdf_base64 = base64.b64encode(pdf_content).decode('utf-8') + + # Add PDF to response for frontend download + transaction_details["invoice_pdf"] = pdf_base64 + transaction_details["invoice_number"] = invoice_number + + # Send donation confirmation email with PDF attachment + email_context = { + "email": notes.get('email', ''), + "name": notes.get('name', ''), + "amount": float(payment_data['amount']) / 100, + "currency": payment_data.get('currency', 'INR'), + "pan_number": notes.get('pan_number', ''), + "address": notes.get('address', ''), + "company": notes.get('company', ''), + "donation_type": donation_type.replace('-', ' ').title(), + } + try: + send_template_mail( + context=email_context, + subject="Thank You for Your Donation to µLearn!", + address=["donation_confirmation.html"], + attachment=("Mulearn_Donation_Receipt.pdf", pdf_content, "application/pdf"), + ) + except Exception as e: + # Log the error but don't fail the payment verification + print(f"Failed to send donation confirmation email: {str(e)}") + + return CustomResponse(response=transaction_details).get_success_response() + except razorpay.errors.SignatureVerificationError as e: + return CustomResponse(general_message="Payment Verification Failed").get_failure_response() + + +# ============================================ +# RECURRING PAYMENT APIs (Subscriptions) +# ============================================ + +class RazorPaySubscriptionAPI(APIView): + + @extend_schema( + tags=['Donate'], + description="Create Razor Pay Subscription.", + request=SubscriptionSerializer, + responses={200: SubscriptionSerializer}, + ) + def post(self, request): + try: + serializer = SubscriptionSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse(message=serializer.errors).get_failure_response() + + validated_data = serializer.validated_data + amount = int(float(validated_data.get("amount")) * 100) # Convert to paise + currency = validated_data.get("currency", "INR") + donation_type = validated_data.get("donation_type") + + # Determine interval based on donation type + if donation_type == "monthly": + period = "monthly" + interval = 1 + else: # yearly + period = "yearly" + interval = 1 + + + plan_name = f"Donation_{donation_type}_{amount}_{uuid.uuid4().hex[:8]}" + + plan_data = { + "period": period, + "interval": interval, + "item": { + "name": f"µLearn {donation_type.capitalize()} Donation - ₹{amount // 100}", + "amount": amount, + "currency": currency, + "description": f"{donation_type.capitalize()} recurring donation to µLearn Foundation" + }, + "notes": { + "donor_name": validated_data.get("name"), + "donor_email": validated_data.get("email"), + "donor_phone_number": validated_data.get("phone_number", ""), + "donor_pan_number": validated_data.get("pan_number", ""), + "donor_address": validated_data.get("address", ""), + "company": validated_data.get("company", ""), + "is_organisation": str(validated_data.get("is_organisation", False)), + } + } + + plan = razorpay_client.plan.create(plan_data) + plan_id = plan['id'] + + # Create serializable version of validated_data for notes + serializable_data = { + "name": validated_data.get("name"), + "email": validated_data.get("email"), + "phone_number": validated_data.get("phone_number", ""), + "pan_number": validated_data.get("pan_number", ""), + "address": validated_data.get("address", ""), + "company": validated_data.get("company", ""), + "amount": float(validated_data.get("amount")), + "currency": currency, + } + + # Step 2: Create a Subscription + subscription_data = { + "plan_id": plan_id, + "total_count": 12 if donation_type == "monthly" else 5, # 12 months or 5 years + "quantity": 1, + "customer_notify": 1, + "notes": { + "donor_name": validated_data.get("name"), + "donor_email": validated_data.get("email"), + "donor_phone_number": validated_data.get("phone_number", ""), + "donor_pan_number": validated_data.get("pan_number", ""), + "donor_address": validated_data.get("address", ""), + "company": validated_data.get("company", ""), + "is_organisation": str(validated_data.get("is_organisation", False)), + "donation_type": donation_type, + "amount": str(float(validated_data.get("amount"))), + } + } + + subscription = razorpay_client.subscription.create(subscription_data) + + return CustomResponse(response={ + "subscription_id": subscription['id'], + "plan_id": plan_id, + "status": subscription['status'], + "short_url": subscription.get('short_url', ''), + "amount": amount, + "currency": currency, + "donation_type": donation_type, + }).get_success_response() + + except razorpay.errors.BadRequestError as e: + return CustomResponse(general_message=str(e)).get_failure_response() + except Exception as e: + return CustomResponse(general_message=f"Error creating subscription: {str(e)}").get_failure_response() + + +class RazorPaySubscriptionVerification(APIView): + + @extend_schema( + tags=['Donate'], + description="Create Razor Pay Subscription Verification.", + request=DonationSerializer, + responses={200: DonationSerializer}, + ) + def post(self, request): + try: + subscription_id = request.data.get("razorpay_subscription_id") + payment_id = request.data.get("razorpay_payment_id") + signature = request.data.get("razorpay_signature") + + # Verify subscription payment signature + razorpay_client.utility.verify_subscription_payment_signature({ + "razorpay_subscription_id": subscription_id, + "razorpay_payment_id": payment_id, + "razorpay_signature": signature, + }) + + # Fetch subscription details + subscription = razorpay_client.subscription.fetch(subscription_id) + + # Fetch payment details + payment = razorpay_client.payment.fetch(payment_id) + + notes = subscription.get('notes', {}) + donation_type = notes.get('donation_type', 'monthly') + + # Prepare donor data + donor_data = { + 'name': notes.get('donor_name', ''), + 'email': notes.get('donor_email', ''), + 'phone_number': notes.get('donor_phone_number', ''), + 'pan_number': notes.get('donor_pan_number', ''), + 'address': notes.get('donor_address', ''), + 'company': notes.get('company', ''), + 'is_organisation': notes.get('is_organisation', 'False') == 'True', + } + + # Get or create donor + donor = get_or_create_donor(donor_data['email'], donor_data) + + # Create donation record + donation_data = { + 'donor': donor.id, + 'order_id': subscription_id, # Store subscription_id as order_id + 'payment_id': payment_id, + 'payment_method': payment.get('method', ''), + 'amount': float(notes.get('amount', payment['amount'] / 100)), + 'currency': payment.get('currency', 'INR'), + 'donation_type': donation_type, + 'is_paid': True, + } + + donation_serializer = DonationSerializer(data=donation_data) + if donation_serializer.is_valid(): + donation_serializer.save() + + # Build transaction details for response transaction_details = { - "Amount": float(data['amount']) / 100, - "Currency": data.get('currency', None), - "payment_id":data['id'], - "Payment_method":data['method'], - "Name": data['notes']['name'], - "Email": data['notes']['email'], - } - if extra_data := data['notes'].get('company', None): - transaction_details["Company"] = extra_data - if extra_data := data['notes'].get('phone_number', None): - transaction_details["Phone Number"] = extra_data - if extra_data := data['notes'].get('pan_number', None): - transaction_details["PAN number"] = extra_data - - serializer = DonorSerializer(data=data['notes']['validated_data']) - if serializer.is_valid(): - serializer.save() - - return CustomResponse(response = transaction_details).get_success_response() + "Amount": float(payment['amount']) / 100, + "Currency": payment.get('currency', 'INR'), + "payment_id": payment_id, + "subscription_id": subscription_id, + "Payment_method": payment.get('method', 'N/A'), + "Name": notes.get('donor_name', ''), + "Email": notes.get('donor_email', ''), + "Donation_Type": donation_type, + "Status": subscription.get('status', ''), + } + + if company := notes.get('company'): + transaction_details["Company"] = company + if phone_number := notes.get('donor_phone_number'): + transaction_details["Phone Number"] = phone_number + if pan_number := notes.get('donor_pan_number'): + transaction_details["PAN number"] = pan_number + if address := notes.get('donor_address'): + transaction_details["Address"] = address + + # Generate invoice PDF + invoice_number = generate_invoice_number() + invoice_data = { + 'invoice_number': invoice_number, + 'date': datetime.now().strftime('%d-%b-%y'), + 'donor_name': notes.get('donor_name', ''), + 'donor_email': notes.get('donor_email', ''), + 'donor_address': notes.get('donor_address', ''), + 'donor_pan': notes.get('donor_pan_number', ''), + 'donor_phone': notes.get('donor_phone_number', ''), + 'company': notes.get('company', ''), + 'amount': float(payment['amount']) / 100, + 'currency': payment.get('currency', 'INR'), + 'payment_id': payment_id, + 'donation_type': f'{donation_type.capitalize()} Recurring Donation', + 'remarks': 'Recurring Sponsorship for Mulearn', + } + + pdf_content = generate_donation_invoice(invoice_data) + pdf_base64 = base64.b64encode(pdf_content).decode('utf-8') + + # Add PDF to response for frontend download + transaction_details["invoice_pdf"] = pdf_base64 + transaction_details["invoice_number"] = invoice_number + + # Send donation confirmation email with PDF attachment + email_context = { + "email": notes.get('donor_email', ''), + "name": notes.get('donor_name', ''), + "amount": float(payment['amount']) / 100, + "currency": payment.get('currency', 'INR'), + "pan_number": notes.get('donor_pan_number', ''), + "address": notes.get('donor_address', ''), + "company": notes.get('company', ''), + "donation_type": f"{donation_type.capitalize()} Recurring", + } + try: + send_template_mail( + context=email_context, + subject="Thank You for Your Recurring Donation to µLearn!", + address=["donation_confirmation.html"], + attachment=("Mulearn_Donation_Receipt.pdf", pdf_content, "application/pdf"), + ) + except Exception as e: + # Log the error but don't fail the payment verification + print(f"Failed to send donation confirmation email: {str(e)}") + + return CustomResponse(response=transaction_details).get_success_response() + except razorpay.errors.SignatureVerificationError as e: - return CustomResponse(general_message = "Payment Verification Failed").get_failure_response() + return CustomResponse(general_message="Subscription Payment Verification Failed").get_failure_response() + except Exception as e: + return CustomResponse(general_message=f"Error verifying subscription: {str(e)}").get_failure_response() + + +# ============================================ +# BANK TRANSFER API (for donations >= 5L) +# ============================================ + +class BankTransferAPI(APIView): + """ + Handle bank transfer donations for amounts >= ₹5,00,000. + Creates a pending verification record without triggering Razorpay or emails. + """ + + @extend_schema( + tags=['Donate'], + description="Create Bank Transfer.", + request=BankTransferSerializer, + responses={200: BankTransferSerializer}, + ) + def post(self, request): + try: + serializer = BankTransferSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse(message=serializer.errors).get_failure_response() + + validated_data = serializer.validated_data + + # Prepare donor data + donor_data = { + 'name': validated_data.get('name', ''), + 'email': validated_data.get('email', ''), + 'phone_number': validated_data.get('phone_number', ''), + 'pan_number': validated_data.get('pan_number', ''), + 'address': validated_data.get('address', ''), + 'company': validated_data.get('company', ''), + 'is_organisation': validated_data.get('is_organisation', False), + } + + # Get or create donor + donor = get_or_create_donor(donor_data['email'], donor_data) + + # Create donation record with pending verification status + donation_data = { + 'donor': donor.id, + 'order_id': None, + 'payment_id': None, + 'donation_name': validated_data.get('donation_name', ''), + 'payment_method': 'bank_transfer', + 'amount': float(validated_data.get('amount')), + 'currency': 'INR', + 'donation_type': validated_data.get('donation_type', 'one-time'), + 'is_paid': False, # Not paid until verified + } + + donation_serializer = DonationSerializer(data=donation_data) + if donation_serializer.is_valid(): + donation = donation_serializer.save() + # Update additional fields that aren't in serializer + donation.payment_status = 'PENDING_VERIFICATION' + donation.reference_code = validated_data.get('reference_code') + donation.proof_url = validated_data.get('proof_url') + donation.save() + else: + return CustomResponse(message=donation_serializer.errors).get_failure_response() + + # Return success response + # NO EMAIL - as per requirements + return CustomResponse( + response={ + 'reference_code': validated_data.get('reference_code'), + 'amount': float(validated_data.get('amount')), + 'status': 'PENDING_VERIFICATION', + 'message': 'Bank transfer donation submitted for verification.', + } + ).get_success_response() + + except ValueError as e: + return CustomResponse(general_message=str(e)).get_failure_response() + except Exception as e: + return CustomResponse(general_message=f"Error processing bank transfer: {str(e)}").get_failure_response() diff --git a/api/hackathon/hackathon_views.py b/api/hackathon/hackathon_views.py index 20a17ebc7..778e60c25 100644 --- a/api/hackathon/hackathon_views.py +++ b/api/hackathon/hackathon_views.py @@ -18,12 +18,18 @@ HackathonInfoSerializer, HackathonUserSubmissionSerializer, ListApplicantsSerializer, \ HackathonOrganiserSerializerRetrieval, HackathonOrganiserSerializer, HackathonFormSerializer, DistrictSerializer, \ OrganisationSerializer +from drf_spectacular.utils import extend_schema, OpenApiResponse class HackathonManagementAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Retrieve Hackathon Management.", + responses={200: UpcomingHackathonRetrievalSerializer}, + ) def get(self, request, hackathon_id=None): user_id = JWTUtils.fetch_user_id(request) if request.path.endswith("upcoming/"): @@ -52,6 +58,12 @@ def get(self, request, hackathon_id=None): return CustomResponse(response=serializer.data).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Create Hackathon Management.", + request=HackathonCreateUpdateDeleteSerializer, + responses={200: UpcomingHackathonRetrievalSerializer}, + ) def post(self, request): serializer = HackathonCreateUpdateDeleteSerializer( data=request.data, context={"request": request} @@ -65,6 +77,11 @@ def post(self, request): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Update Hackathon Management.", + responses={200: HackathonUpdateSerializer}, + ) def put(self, request, hackathon_id): hackathon = Hackathon.objects.filter(id=hackathon_id).first() if hackathon is None: @@ -82,6 +99,11 @@ def put(self, request, hackathon_id): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Delete Hackathon Management.", + responses={200: HackathonCreateUpdateDeleteSerializer}, + ) def delete(self, request, hackathon_id): hackathon = Hackathon.objects.filter(id=hackathon_id).first() if hackathon is None: @@ -99,6 +121,11 @@ class HackathonPublishingAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Update Hackathon Publishing.", + responses={200: HackathonPublishingSerializer}, + ) def put(self, request, hackathon_id): hackathon = Hackathon.objects.filter(id=hackathon_id).first() if hackathon is None: @@ -120,6 +147,11 @@ class HackathonInfoAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Retrieve Hackathon Info.", + responses={200: HackathonInfoSerializer}, + ) def get(self, request, hackathon_id): hackathon = Hackathon.objects.filter(id=hackathon_id).first() serializer = HackathonInfoSerializer( @@ -132,6 +164,9 @@ class GetDefaultFieldsAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Hackathon'], description="Retrieve Get Default Fields.", + responses={200: OpenApiResponse(description="List of default hackathon form fields")}, + ) def get(self, request): return CustomResponse( response=DEFAULT_HACKATHON_FORM_FIELDS @@ -142,6 +177,12 @@ class HackathonSubmissionAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Create Hackathon Submission.", + request=HackathonUserSubmissionSerializer, + responses={200: HackathonUserSubmissionSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) hackathon_id = request.data.get('hackathon_id') @@ -164,6 +205,11 @@ class ListApplicantsAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Retrieve List Applicants.", + responses={200: ListApplicantsSerializer}, + ) def get(self, request, hackathon_id=None): if hackathon_id: data = HackathonUserSubmission.objects.filter(hackathon__id=hackathon_id) @@ -182,6 +228,9 @@ class HackathonOrganiserAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Hackathon'], description="Retrieve Hackathon Organiser.", + responses={200: HackathonOrganiserSerializer}, + ) def get(self, request, hackathon_id): hackathon_ids = HackathonOrganiserLink.objects.filter( hackathon__id=hackathon_id @@ -192,6 +241,12 @@ def get(self, request, hackathon_id): return CustomResponse(response=serializer.data).get_success_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Create Hackathon Organiser.", + request=HackathonOrganiserSerializer, + responses={200: HackathonOrganiserSerializer}, + ) def post(self, request, hackathon_id): hackathon = Hackathon.objects.filter(id=hackathon_id).first() if hackathon is None: @@ -210,6 +265,11 @@ def post(self, request, hackathon_id): return CustomResponse(message=serializer.errors).get_failure_response() @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Delete Hackathon Organiser.", + responses={200: HackathonOrganiserSerializer}, + ) def delete(self, request, organiser_link_id): organiser = HackathonOrganiserLink.objects.filter(id=organiser_link_id).first() if organiser is None: @@ -227,6 +287,11 @@ class ListOrganisations(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Retrieve List Organisations.", + responses={200: OrganisationSerializer}, + ) def get(self, request): organisations = Organization.objects.all() serializer = OrganisationSerializer( @@ -239,6 +304,11 @@ class ListDistricts(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Retrieve List Districts.", + responses={200: DistrictSerializer}, + ) def get(self, request): districts = District.objects.all() serializer = DistrictSerializer(districts, many=True) @@ -249,6 +319,11 @@ class ListHackathonFormAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Hackathon'], + description="Retrieve List Hackathon Form.", + responses={200: HackathonFormSerializer}, + ) def get(self, request, hackathon_id): hackathon = Hackathon.objects.filter(id=hackathon_id).first() if hackathon is None: diff --git a/api/integrations/kkem/kkem_views.py b/api/integrations/kkem/kkem_views.py index 44ccb69e9..91383ee51 100644 --- a/api/integrations/kkem/kkem_views.py +++ b/api/integrations/kkem/kkem_views.py @@ -17,10 +17,17 @@ from .. import integrations_helper from . import kkem_helper from .kkem_serializer import KKEMAuthorization, KKEMUserSerializer +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s class KKEMBulkKarmaAPI(APIView): @integrations_helper.token_required(IntegrationType.KKEM.value) + @extend_schema( + tags=['Integrations - Kkem'], + description="Retrieve K K E M Bulk Karma.", + responses={200: KKEMUserSerializer}, + ) def get(self, request): base_queryset = ( User.objects.filter( @@ -72,6 +79,11 @@ def get(self, request): class KKEMIndividualKarmaAPI(APIView): @integrations_helper.token_required(IntegrationType.KKEM.value) + @extend_schema( + tags=['Integrations - Kkem'], + description="Retrieve K K E M Individual Karma.", + responses={200: KKEMUserSerializer}, + ) def get(self, request, muid): kkem_user = ( User.objects.filter( @@ -93,6 +105,9 @@ def get(self, request, muid): class KKEMAuthorizationAPI(APIView): + @extend_schema(tags=['Integrations - Kkem'], description="Create K K E M Authorization.", + responses={200: OpenApiResponse(description="Authorization created; confirmation email sent.")}, + ) def post(self, request): request.data["verified"] = False kkem_auth_serializer = KKEMAuthorization( @@ -125,6 +140,15 @@ def post(self, request): except CustomException as e: return CustomResponse(general_message=str(e)).get_failure_response() + @extend_schema(tags=['Integrations - Kkem'], description="Partially update K K E M Authorization.", + responses={200: inline_serializer( + name='KkemAuthorizationPatchResponse', + fields={ + 'accessToken': s.CharField(), + 'refreshToken': s.CharField(), + }, + )}, + ) def patch(self, request, token): try: link_id = integrations_helper.get_authorization_id(token) @@ -153,6 +177,26 @@ def patch(self, request, token): class KKEMIntegrationLogin(APIView): + @extend_schema(tags=['Integrations - Kkem'], description="Create K K E M Integration Login.", + responses={200: inline_serializer( + name='KkemIntegrationLoginResponse', + fields={ + 'accessToken': s.CharField(), + 'refreshToken': s.CharField(), + 'data': inline_serializer( + name='KkemIntegrationLoginDataResponse', + fields={ + 'email': s.CharField(), + 'full_name': s.CharField(), + 'muid': s.CharField(), + 'link_id': s.CharField(), + 'verified': s.BooleanField(), + }, + allow_null=True, required=False, + ), + }, + )}, + ) def post(self, request): try: email_or_muid = request.data.get("emailOrMuid") @@ -189,6 +233,9 @@ def post(self, request): class KKEMdetailsFetchAPI(APIView): + @extend_schema(tags=['Integrations - Kkem'], description="Retrieve K K E Mdetails Fetch.", + responses={200: OpenApiResponse(description="KKEM jobseeker details returned from the KKEM API.")}, + ) def get(self, request, encrypted_data): try: details = kkem_helper.decrypt_kkem_data(encrypted_data) @@ -223,6 +270,9 @@ def get(self, request, encrypted_data): class KKEMUserStatusAPI(APIView): + @extend_schema(tags=['Integrations - Kkem'], description="Retrieve K K E M User Status.", + responses={200: KKEMUserSerializer}, + ) def get(self, request, encrypted_data): try: details = kkem_helper.decrypt_kkem_data(encrypted_data) @@ -236,6 +286,20 @@ def get(self, request, encrypted_data): class HackathonStatsAPI(APIView): @integrations_helper.token_required(IntegrationType.KKEM.value) + @extend_schema(tags=['Integrations - Kkem'], description="Retrieve Hackathon Stats.", + responses={200: inline_serializer( + name='KkemHackathonStatResponse', + fields={ + 'hackathon': s.CharField(), + 'registered_people': s.IntegerField(), + 'shortlisted_people': s.IntegerField(), + 'selection_criteria': s.CharField(), + 'total_participants': s.IntegerField(), + 'winning_team_members': s.IntegerField(), + }, + many=True, + )}, + ) def get(self, request): return CustomResponse( response=[ diff --git a/api/integrations/mulearn_api/__init__.py b/api/integrations/mulearn_api/__init__.py new file mode 100644 index 000000000..005d55086 --- /dev/null +++ b/api/integrations/mulearn_api/__init__.py @@ -0,0 +1 @@ +# Empty init diff --git a/api/integrations/mulearn_api/urls.py b/api/integrations/mulearn_api/urls.py new file mode 100644 index 000000000..fa9d38919 --- /dev/null +++ b/api/integrations/mulearn_api/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import ExternalTaskVerificationAPI + +urlpatterns = [ + path('verify-task/', ExternalTaskVerificationAPI.as_view(), name='external-verify-task'), +] diff --git a/api/integrations/mulearn_api/views.py b/api/integrations/mulearn_api/views.py new file mode 100644 index 000000000..8eee4e0b8 --- /dev/null +++ b/api/integrations/mulearn_api/views.py @@ -0,0 +1,43 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from db.user import User +from db.task import TaskList, KarmaActivityLog +from utils.response import CustomResponse + +class ExternalTaskVerificationAPI(APIView): + def get(self, request): + muid = request.query_params.get('muid') + email = request.query_params.get('email') + hashtag = request.query_params.get('hashtag') + + if not all([muid, email, hashtag]): + return CustomResponse(general_message=["The 'muid', 'email', and 'hashtag' query parameters are required."]).get_failure_response(status_code=400, http_status_code=status.HTTP_400_BAD_REQUEST) + + try: + user = User.every.get(muid=muid, email=email) + except User.DoesNotExist: + return CustomResponse(general_message=["The user with the specified muid and email does not exist."]).get_failure_response(status_code=404, http_status_code=status.HTTP_404_NOT_FOUND) + + try: + task = TaskList.objects.get(hashtag=hashtag) + except TaskList.DoesNotExist: + return CustomResponse(general_message=["The task with the specified hashtag does not exist."]).get_failure_response(status_code=404, http_status_code=status.HTTP_404_NOT_FOUND) + + log = KarmaActivityLog.objects.filter( + user=user, + task=task, + appraiser_approved=True + ).order_by('-updated_at').first() + + if log: + verified = True + verified_at = log.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ') if log.updated_at else None + else: + verified = False + verified_at = None + + return CustomResponse(response={ + "verified": verified, + "verified_at": verified_at, + }).get_success_response() diff --git a/api/integrations/qseverse/qseverse_views.py b/api/integrations/qseverse/qseverse_views.py index 0e32dda9d..7de9c9430 100644 --- a/api/integrations/qseverse/qseverse_views.py +++ b/api/integrations/qseverse/qseverse_views.py @@ -4,6 +4,8 @@ from rest_framework.views import APIView from utils.response import CustomResponse from .serializers import IssueVCSerializer +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s BASE_URL = settings.QSEVERSE_BASE_URL @@ -15,6 +17,12 @@ class IssueVerifiableCredentialView(APIView): + @extend_schema( + tags=['Integrations - Qseverse'], + description="Create Issue Verifiable Credential.", + request=IssueVCSerializer, + responses={200: IssueVCSerializer}, + ) def post(self, request): serializer = IssueVCSerializer(data=request.data) if serializer.is_valid(): @@ -43,6 +51,17 @@ def post(self, request): class GetAllConnectedUsersView(APIView): + @extend_schema(tags=['Integrations - Qseverse'], description="Retrieve Get All Connected Users.", + responses={200: inline_serializer( + name='QseverseConnectedUserItem', + fields={ + 'did': s.CharField(), + 'name': s.CharField(required=False, allow_null=True), + 'email': s.EmailField(required=False, allow_null=True), + }, + many=True, + )}, + ) def get(self, request): try: response = requests.get( @@ -60,6 +79,14 @@ def get(self, request): class GetConnectedUserView(APIView): + @extend_schema(tags=['Integrations - Qseverse'], description="Retrieve Get Connected User.", + responses={200: inline_serializer( + name='QseverseConnectedUserDidsResponse', + fields={ + 'dids': s.ListField(child=s.CharField()), + }, + )}, + ) def get(self, request): key = request.query_params.get("key") value = request.query_params.get("value") @@ -78,10 +105,10 @@ def get(self, request): response.raise_for_status() data = response.json() matching_users = data.get("matching_users", []) - did = matching_users[0].get("did") if matching_users else None + dids = [user.get("did") for user in matching_users if user.get("did")] return CustomResponse( - response={"did": did} + response={"dids": dids} ).get_success_response() except requests.exceptions.RequestException as e: return CustomResponse( @@ -90,6 +117,19 @@ def get(self, request): class GetQSCredentialsView(APIView): + @extend_schema(tags=['Integrations - Qseverse'], description="Retrieve Get Q S Credentials.", + responses={200: inline_serializer( + name='QseverseCredentialItem', + fields={ + 'credentialId': s.CharField(), + 'templateId': s.CharField(), + 'issuedTo': s.CharField(required=False, allow_null=True), + 'issuedAt': s.CharField(required=False, allow_null=True), + 'status': s.CharField(required=False, allow_null=True), + }, + many=True, + )}, + ) def get(self, request): try: response = requests.get( diff --git a/api/integrations/qseverse/serializers.py b/api/integrations/qseverse/serializers.py index 0c3840dfc..74ddcd3ad 100644 --- a/api/integrations/qseverse/serializers.py +++ b/api/integrations/qseverse/serializers.py @@ -3,7 +3,9 @@ from rest_framework import serializers class SubjectInfoSerializer(serializers.Serializer): - name = serializers.CharField() + type = serializers.CharField() + did = serializers.CharField() + full_name = serializers.CharField() email = serializers.EmailField() phone = serializers.CharField(required=False) diff --git a/api/integrations/urls.py b/api/integrations/urls.py index be5d63b75..b42a69103 100644 --- a/api/integrations/urls.py +++ b/api/integrations/urls.py @@ -5,4 +5,5 @@ path('kkem/', include('api.integrations.kkem.urls')), path('wadhwani/', include('api.integrations.wadhwani.urls')), path('qseverse/', include('api.integrations.qseverse.urls', )), + path('mufifa/', include('api.integrations.mulearn_api.urls')), ] diff --git a/api/integrations/wadhwani/wadhwani_views.py b/api/integrations/wadhwani/wadhwani_views.py index 70627777a..96192cd75 100644 --- a/api/integrations/wadhwani/wadhwani_views.py +++ b/api/integrations/wadhwani/wadhwani_views.py @@ -7,9 +7,22 @@ from rest_framework.views import APIView from django.conf import settings +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s class WadhwaniAuthToken(APIView): + @extend_schema(tags=['Integrations - Wadhwani'], description="Create Wadhwani Auth Token.", + responses={200: inline_serializer( + name='WadhwaniAuthTokenResponse', + fields={ + 'access_token': s.CharField(), + 'token_type': s.CharField(), + 'expires_in': s.IntegerField(), + 'scope': s.CharField(required=False, allow_blank=True), + }, + )}, + ) def post(self, request): url = settings.WADHWANI_CLIENT_AUTH_URL @@ -29,6 +42,19 @@ def post(self, request): class WadhwaniUserLogin(APIView): + @extend_schema(tags=['Integrations - Wadhwani'], description="Create Wadhwani User Login.", + responses={200: inline_serializer( + name='WadhwaniUserLoginResponse', + fields={ + 'status': s.CharField(), + 'accessToken': s.CharField(required=False), + 'expiresIn': s.IntegerField(required=False), + 'userCreated': s.BooleanField(required=False), + 'courseEnrolled': s.BooleanField(required=False), + 'redirectionUrl': s.CharField(required=False, allow_blank=True), + }, + )}, + ) def post(self, request): url = settings.WADHWANI_BASE_URL + "/api/v1/iamservice/oauth/login" user_id = JWTUtils.fetch_user_id(request) @@ -75,6 +101,26 @@ def post(self, request): class WadhwaniCourseDetails(APIView): + @extend_schema(tags=['Integrations - Wadhwani'], description="Create Wadhwani Course Details.", + responses={200: inline_serializer( + name='WadhwaniCourseDetailsResponse', + fields={ + 'status': s.CharField(), + 'courses': s.ListField( + child=inline_serializer( + name='WadhwaniCourseItem', + fields={ + 'courseRootId': s.CharField(), + 'name': s.CharField(), + 'description': s.CharField(required=False, allow_blank=True), + 'thumbnailUrl': s.CharField(required=False, allow_null=True), + }, + ), + required=False, + ), + }, + )}, + ) def post(self, request): url = settings.WADHWANI_BASE_URL + "/api/v1/courseservice/oauth/client/courses" @@ -94,6 +140,26 @@ def post(self, request): class WadhwaniCourseEnrollStatus(APIView): + @extend_schema(tags=['Integrations - Wadhwani'], description="Create Wadhwani Course Enroll Status.", + responses={200: inline_serializer( + name='WadhwaniCourseEnrollStatusResponse', + fields={ + 'status': s.CharField(), + 'enrolledCourses': s.ListField( + child=inline_serializer( + name='WadhwaniEnrolledCourseItem', + fields={ + 'courseRootId': s.CharField(), + 'name': s.CharField(), + 'enrollmentDate': s.CharField(required=False, allow_null=True), + 'completionStatus': s.CharField(required=False, allow_null=True), + }, + ), + required=False, + ), + }, + )}, + ) def post(self, request): url = settings.WADHWANI_BASE_URL + "/api/v1/courseservice/oauth/client/courses" user_id = JWTUtils.fetch_user_id(request) @@ -116,6 +182,27 @@ def post(self, request): class WadhwaniCourseQuizData(APIView): + @extend_schema(tags=['Integrations - Wadhwani'], description="Create Wadhwani Course Quiz Data.", + responses={200: inline_serializer( + name='WadhwaniCourseQuizDataResponse', + fields={ + 'status': s.CharField(), + 'quizData': s.ListField( + child=inline_serializer( + name='WadhwaniQuizItem', + fields={ + 'quizId': s.CharField(), + 'quizName': s.CharField(), + 'score': s.FloatField(required=False, allow_null=True), + 'totalMarks': s.FloatField(required=False, allow_null=True), + 'attemptDate': s.CharField(required=False, allow_null=True), + }, + ), + required=False, + ), + }, + )}, + ) def post(self, request): if not (token := request.data.get("Client-Auth-Token", None)): return CustomResponse( diff --git a/api/launchpad/launchpad_views.py b/api/launchpad/launchpad_views.py index f45de41c1..b54121dc8 100644 --- a/api/launchpad/launchpad_views.py +++ b/api/launchpad/launchpad_views.py @@ -40,6 +40,9 @@ from django.template.loader import render_to_string import decouple import secrets +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s +from utils.schema_utils import CustomResponseSerializer def send_template_mail(context: dict, subject: str, address: List[str], attachment: str = None): @@ -116,6 +119,12 @@ def generate_launchpad_jwt(user, user_type): class RegisterCompanyAPI(APIView): + @extend_schema( + tags=['Launchpad'], + description="Create Register Company.", + request=LaunchpadCompaniesSerializer, + responses={200: LaunchpadCompaniesSerializer}, + ) def post(self, request): required_fields = ['name', 'username'] for field in required_fields: @@ -175,6 +184,19 @@ class CompanyListAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Launchpad'], description="Retrieve Company List.", + responses={200: inline_serializer("LaunchpadCompanyListResponse", fields={ + "id": s.CharField(), + "name": s.CharField(), + "poc_name": s.CharField(allow_null=True), + "poc_email": s.CharField(allow_null=True), + "poc_phone": s.CharField(allow_null=True), + "website": s.CharField(allow_null=True), + "description": s.CharField(allow_null=True), + "address": s.CharField(allow_null=True), + "is_verified": s.BooleanField(), + })}, + ) def get(self, request): companies = LaunchpadCompanies.objects.all() data = [ @@ -196,6 +218,11 @@ def get(self, request): ).get_success_response() class CompanyListVerifiedAPI(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve Company List Verified.", + responses={200: LaunchpadCompanyPublicSerializer}, + ) def get(self, request): companies = LaunchpadCompanies.objects.filter(is_verified=True) @@ -213,6 +240,12 @@ def get(self, request): class RegisterRecruiterAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema( + tags=['Launchpad'], + description="Create Register Recruiter.", + request=LaunchpadRecruiterSerializer, + responses={200: LaunchpadRecruiterSerializer}, + ) def post(self, request): print(request.auth) if request.auth["user_type"] != "company": @@ -268,6 +301,12 @@ def post(self, request): class AddJobAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema( + tags=['Launchpad'], + description="Create Add Job.", + request=LaunchpadJobsSerializer, + responses={200: LaunchpadJobsSerializer}, + ) def post(self, request): if request.auth["user_type"] != "recruiter": return CustomResponse(general_message="Only recruiters can add jobs.").get_failure_response() @@ -380,6 +419,9 @@ def post(self, request): class JobAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema(tags=['Launchpad'], description="Retrieve Job.", + responses={200: LaunchpadJobUpdateSerializer}, + ) def get(self, request, job_id): user_type = request.auth["user_type"] user_id = request.auth["id"] @@ -435,6 +477,9 @@ def get(self, request, job_id): general_message="Job not found." ).get_failure_response() + @extend_schema(tags=['Launchpad'], description="Update Job.", + responses={200: LaunchpadJobUpdateSerializer}, + ) def put(self, request, job_id): user_type = request.auth["user_type"] user_id = request.auth["id"] @@ -513,6 +558,9 @@ def put(self, request, job_id): general_message="Job update failed" ).get_failure_response() + @extend_schema(tags=['Launchpad'], description="Delete Job.", + responses={200: LaunchpadJobUpdateSerializer}, + ) def delete(self, request, job_id): user_type = request.auth["user_type"] user_id = request.auth["id"] @@ -548,6 +596,12 @@ def delete(self, request, job_id): class ListJobsAPI(APIView): authentication_classes = [CustomizePermission, LaunchpadJWTPermission] + @extend_schema(tags=['Launchpad'], description="Retrieve List Jobs.", + responses={200: inline_serializer("LaunchpadListJobsResponse", fields={ + "jobs": s.ListField(child=s.DictField()), + "summary": s.DictField(), + })}, + ) def get(self, request): user_type = request.auth.get("user_type") user_id = request.auth.get("id") @@ -640,6 +694,12 @@ def get(self, request): class VerifyTaskAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema( + tags=['Launchpad'], + description="Create Verify Task.", + request=TaskVerificationSerializer, + responses={200: TaskVerificationSerializer}, + ) def post(self, request): serializer = TaskVerificationSerializer(data=request.data) @@ -666,6 +726,18 @@ def post(self, request): ).get_failure_response() class LoginCompanyAPI(APIView): + @extend_schema(tags=['Launchpad'], description="Create Login Company.", + responses={200: inline_serializer("LaunchpadLoginCompanyResponse", fields={ + "id": s.CharField(), + "name": s.CharField(), + "username": s.CharField(), + "poc_name": s.CharField(allow_null=True), + "poc_email": s.CharField(allow_null=True), + "created_at": s.DateTimeField(), + "accessToken": s.CharField(), + "refreshToken": s.CharField(), + })}, + ) def post(self, request): data = request.data usernameOrEmail = data.get("username") or data.get("email") @@ -700,6 +772,19 @@ def post(self, request): }).get_success_response() class LoginRecruiterAPI(APIView): + @extend_schema(tags=['Launchpad'], description="Create Login Recruiter.", + responses={200: inline_serializer("LaunchpadLoginRecruiterResponse", fields={ + "id": s.CharField(), + "name": s.CharField(), + "email": s.CharField(), + "phone": s.CharField(allow_null=True), + "role": s.CharField(allow_null=True), + "company_id": s.CharField(), + "created_at": s.DateTimeField(), + "accessToken": s.CharField(), + "refreshToken": s.CharField(), + })}, + ) def post(self, request): data = request.data emailOrPhone = data.get('email') or data.get('phone') @@ -739,6 +824,24 @@ def post(self, request): }).get_success_response() class GetCompanyInfoAPI(APIView): + @extend_schema(tags=['Launchpad'], description="Create Get Company Info.", + responses={200: inline_serializer("LaunchpadCompanyInfoResponse", fields={ + "id": s.CharField(), + "name": s.CharField(), + "username": s.CharField(), + "poc_name": s.CharField(allow_null=True), + "poc_role": s.CharField(allow_null=True), + "poc_email": s.CharField(allow_null=True), + "poc_phone": s.CharField(allow_null=True), + "website": s.CharField(allow_null=True), + "description": s.CharField(allow_null=True), + "address": s.CharField(allow_null=True), + "is_verified": s.BooleanField(), + "created_at": s.DateTimeField(), + "updated_at": s.DateTimeField(), + "recruiters": s.ListField(child=s.DictField()), + })}, + ) def post(self, request): company_id = request.data.get('company_id') if not company_id: @@ -778,6 +881,19 @@ def post(self, request): return CustomResponse(general_message="Company not found.").get_failure_response() class GetRecruiterInfoAPI(APIView): + @extend_schema(tags=['Launchpad'], description="Create Get Recruiter Info.", + responses={200: inline_serializer("LaunchpadRecruiterInfoResponse", fields={ + "id": s.CharField(), + "name": s.CharField(), + "email": s.CharField(), + "phone": s.CharField(allow_null=True), + "role": s.CharField(allow_null=True), + "company_id": s.CharField(), + "company_name": s.CharField(allow_null=True), + "created_at": s.DateTimeField(), + "updated_at": s.DateTimeField(), + })}, + ) def post(self, request): recruiter_id = request.data.get('recruiter_id') if not recruiter_id: @@ -801,6 +917,12 @@ def post(self, request): class RefreshTokenAPI(APIView): + @extend_schema(tags=['Launchpad'], description="Create Refresh Token.", + responses={200: inline_serializer("LaunchpadRefreshTokenResponse", fields={ + "accessToken": s.CharField(), + "refreshToken": s.CharField(), + })}, + ) def post(self, request): refresh_token = request.data.get("refreshToken") if not refresh_token: @@ -851,6 +973,12 @@ def post(self, request): class CompanyVerifyAPI(APIView): authentication_classes = [CustomizePermission] @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Launchpad'], description="Create Company Verify.", + responses={200: inline_serializer("LaunchpadCompanyVerifyResponse", fields={ + "company_name": s.CharField(), + "message": s.CharField(), + })}, + ) def post(self, request): company_id = request.data.get('company_id') if not company_id: @@ -887,6 +1015,11 @@ def post(self, request): class ListLaunchpadStudentsAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema( + tags=['Launchpad'], + description="Retrieve List Launchpad Students.", + responses={200: EligibleStudentSerializer}, + ) def get(self, request, job_id): user_type = request.auth["user_type"] if user_type not in ["recruiter", "company"]: @@ -1073,6 +1206,12 @@ def get(self, request, job_id): class HireRequestsAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema(tags=['Launchpad'], description="Retrieve Hire Requests.", + responses={200: inline_serializer("LaunchpadHireRequestsResponse", fields={ + "data": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + })}, + ) def get(self, request): user_type = request.auth["user_type"] if user_type not in ["recruiter", "company"]: @@ -1349,6 +1488,14 @@ def get(self, request): class SendJobInvitationsAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema(tags=['Launchpad'], description="Create Send Job Invitations.", + responses={200: inline_serializer("LaunchpadSendInvitationsResponse", fields={ + "job_info": s.DictField(), + "invitation_summary": s.DictField(), + "invited_students": s.ListField(child=s.DictField()), + "already_invited_students": s.ListField(child=s.DictField()), + })}, + ) def post(self, request): if request.auth["user_type"] != "recruiter": return CustomResponse(general_message="Only recruiters can send invitations.").get_failure_response() @@ -1450,6 +1597,12 @@ def post(self, request): class StudentJobInvitationsAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Launchpad'], description="Retrieve Student Job Invitations.", + responses={200: inline_serializer("LaunchpadStudentInvitationsResponse", fields={ + "data": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + })}, + ) def get(self, request): user_id = request.auth["id"] status_filter = request.query_params.get('status', None) @@ -1536,6 +1689,15 @@ def get(self, request): class StudentApplyToJobAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Launchpad'], description="Create Student Apply To Job.", + responses={200: inline_serializer("LaunchpadStudentApplyResponse", fields={ + "application_id": s.CharField(), + "job_info": s.DictField(), + "application_details": s.DictField(), + "status": s.CharField(), + "applied_at": s.DateTimeField(), + })}, + ) def post(self, request): try: user_id = request.auth["id"] @@ -1599,6 +1761,12 @@ def post(self, request): class AcceptedStudentsAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema(tags=['Launchpad'], description="Retrieve Accepted Students.", + responses={200: inline_serializer("LaunchpadAcceptedStudentsResponse", fields={ + "data": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + })}, + ) def get(self, request, job_id=None): if request.auth["user_type"] != "recruiter": return CustomResponse(general_message="Only recruiters can view students.").get_failure_response() @@ -1748,6 +1916,15 @@ def get(self, request, job_id=None): ) class ScheduleInterviewAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema(tags=['Launchpad'], description="Create Schedule Interview.", + responses={200: inline_serializer("LaunchpadScheduleInterviewResponse", fields={ + "application_id": s.CharField(), + "interview_date": s.DateField(), + "interview_platform": s.CharField(allow_null=True), + "interview_time": s.TimeField(allow_null=True), + "status": s.CharField(), + })}, + ) def post(self, request): user_id = request.auth["id"] application_id = request.data.get('application_id') @@ -1801,6 +1978,18 @@ def post(self, request): class ApplicationFinalDecisionAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema(tags=['Launchpad'], description="Create Application Final Decision.", + responses={200: inline_serializer("LaunchpadApplicationDecisionResponse", fields={ + "application_id": s.CharField(), + "decision": s.CharField(), + "previous_status": s.CharField(), + "decision_made_at": s.DateTimeField(), + "decision_made_by": s.DictField(), + "student_info": s.DictField(), + "job_info": s.DictField(), + "application_timeline": s.DictField(), + })}, + ) def post(self, request): user_type = request.auth["user_type"] if user_type not in ["recruiter", "company"]: @@ -1909,6 +2098,9 @@ def post(self, request): class DeleteCompanyAPI(APIView): @role_required([RoleType.ADMIN.value]) + @extend_schema(tags=['Launchpad'], description="Partially update Delete Company.", + responses={200: OpenApiResponse(description="Company unverified successfully.")}, + ) def patch(self, request): company_id = request.data.get("id") @@ -1934,6 +2126,11 @@ def patch(self, request): #<--------------------------------------------------- old launchpad -------------------------------------------------> class Leaderboard(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve Leaderboard.", + responses={200: LaunchpadLeaderBoardSerializer}, + ) def get(self, request): total_karma_subquery = ( KarmaActivityLog.objects.filter( @@ -2019,6 +2216,11 @@ def get(self, request): class TaskCompletedLeaderboard(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve Task Completed Leaderboard.", + responses={200: TaskCompletedLeaderBoardSerializer}, + ) def get(self, request): launchpad_tasks = TaskList.objects.filter(event="launchpad").values("id") @@ -2104,6 +2306,11 @@ def get(self, request): class ListParticipantsAPI(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve List Participants.", + responses={200: LaunchpadParticipantsSerializer}, + ) def get(self, request): allowed_org_types = ["College", "School", "Company"] allowed_levels = LaunchPadLevels.get_all_values() @@ -2194,6 +2401,15 @@ def get(self, request): class LaunchpadDetailsCount(APIView): + @extend_schema(tags=['Launchpad'], description="Retrieve Launchpad Details Count.", + responses={200: inline_serializer("LaunchpadDetailsCountResponse", fields={ + "total_participants": s.IntegerField(), + "Level_1": s.IntegerField(), + "Level_2": s.IntegerField(), + "Level_3": s.IntegerField(), + "Level_4": s.IntegerField(), + })}, + ) def get(self, request): allowed_org_types = ["College", "School", "Company"] allowed_levels = LaunchPadLevels.get_all_values() @@ -2265,6 +2481,11 @@ def get(self, request): class CollegeData(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve College Data.", + responses={200: CollegeDataSerializer}, + ) def get(self, request): allowed_levels = LaunchPadLevels.get_all_values() @@ -2343,6 +2564,12 @@ def get(self, request): class LaunchPadUser(APIView): + @extend_schema( + tags=['Launchpad'], + description="Create Launch Pad User.", + request=LaunchpadUserSerializer, + responses={200: LaunchpadUserSerializer}, + ) def post(self, request): data = request.data auth_mail = data.pop("current_user", None) @@ -2386,6 +2613,11 @@ def post(self, request): general_message="Successfully added user" ).get_success_response() + @extend_schema( + tags=['Launchpad'], + description="Retrieve Launch Pad User.", + responses={200: LaunchpadUserListSerializer}, + ) def get(self, request): auth_mail = request.query_params.get("current_user", None) if not LaunchPadUsers.objects.filter( @@ -2406,6 +2638,11 @@ def get(self, request): data=serializer.data, pagination=paginated_queryset.get("pagination") ) + @extend_schema( + tags=['Launchpad'], + description="Update Launch Pad User.", + responses={200: LaunchpadUpdateUserSerializer}, + ) def put(self, request, email): data = request.data auth_mail = data.pop("current_user", None) @@ -2435,6 +2672,11 @@ def put(self, request, email): class LaunchPadUserPublic(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve Launch Pad User Public.", + responses={200: LaunchpadUserListSerializer}, + ) def get(self, request, email): try: user = LaunchPadUsers.objects.get(email=email) @@ -2448,6 +2690,11 @@ def get(self, request, email): class UserProfile(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve User Profile.", + responses={200: LaunchpadUserListSerializer}, + ) def get(self, request): auth_mail = request.query_params.get("current_user", None) if not LaunchPadUsers.objects.filter(email=auth_mail).exists(): @@ -2456,6 +2703,9 @@ def get(self, request): serializer = LaunchpadUserListSerializer(user) return CustomResponse(response=serializer.data).get_success_response() + @extend_schema(tags=['Launchpad'], description="Update User Profile.", + responses={200: LaunchpadUserListSerializer}, + ) def put(self, request): data = request.data auth_mail = data.pop("current_user", None) @@ -2474,6 +2724,11 @@ def put(self, request): class UserBasedCollegeData(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve User Based College Data.", + responses={200: CollegeDataSerializer}, + ) def get(self, request): auth_mail = request.query_params.get("current_user", None) if not LaunchPadUsers.objects.filter(email=auth_mail).exists(): @@ -2543,6 +2798,12 @@ def get(self, request): class BulkLaunchpadUser(APIView): + @extend_schema( + tags=['Launchpad'], + description="Create Bulk Launchpad User.", + request=LaunchpadUserSerializer, + responses={200: LaunchpadUserSerializer}, + ) def post(self, request): data = request.data auth_mail = data.pop("current_user", None) @@ -2610,6 +2871,11 @@ def post(self, request): class LaunchPadListAdmin(APIView): + @extend_schema( + tags=['Launchpad'], + description="Retrieve Launch Pad List Admin.", + responses={200: LaunchpadLeaderBoardSerializer}, + ) def get(self, request): auth_mail = request.query_params.get("current_user", None) auth_mail = auth_mail[0] if isinstance(auth_mail, list) else auth_mail @@ -2790,6 +3056,12 @@ def get(self, request, launchpad_id=None): class IGLeaderboardView(APIView): + @extend_schema(tags=['Launchpad'], description="Retrieve I G Leaderboard.", + responses={200: inline_serializer("LaunchpadIGLeaderboardResponse", fields={ + "data": s.ListField(child=s.DictField()), + "pagination": s.DictField(), + })}, + ) def get(self, request): category = request.query_params.get("category") # ig_id = request.query_params.get("ig_id") @@ -2884,6 +3156,12 @@ def get(self, request): ) class ForgotPasswordAPI(APIView): + @extend_schema( + tags=['Launchpad'], + description="Create Forgot Password.", + request=ForgotPasswordSerializer, + responses={200: ForgotPasswordSerializer}, + ) def post(self, request): serializer = ForgotPasswordSerializer(data=request.data) @@ -2921,7 +3199,7 @@ def post(self, request): # Send reset email try: - reset_link = f"{decouple.config('FR_DOMAIN_NAME')}/reset-password?token={reset_token}&type={user_type}" + reset_link = f"{decouple.config('LAUNCHPAD_FR_DOMAIN_NAME')}/reset-password?token={reset_token}&type={user_type}" send_template_mail( context={ "email": user_email, @@ -2948,6 +3226,12 @@ def post(self, request): ).get_success_response() class ResetPasswordAPI(APIView): + @extend_schema( + tags=['Launchpad'], + description="Create Reset Password.", + request=ResetPasswordSerializer, + responses={200: ResetPasswordSerializer}, + ) def post(self, request): serializer = ResetPasswordSerializer(data=request.data) @@ -2971,6 +3255,13 @@ def post(self, request): ).get_success_response() class VerifyResetTokenAPI(APIView): + @extend_schema(tags=['Launchpad'], description="Create Verify Reset Token.", + responses={200: inline_serializer("LaunchpadVerifyResetTokenResponse", fields={ + "valid": s.BooleanField(), + "user_name": s.CharField(allow_null=True), + "expires_at": s.DateTimeField(allow_null=True), + })}, + ) def post(self, request): token = request.data.get('token') user_type = request.data.get('user_type') @@ -3014,6 +3305,12 @@ def post(self, request): class ChangePasswordAPI(APIView): authentication_classes = [LaunchpadJWTPermission] + @extend_schema( + tags=['Launchpad'], + description="Create Change Password.", + request=ChangePasswordSerializer, + responses={200: ChangePasswordSerializer}, + ) def post(self, request): user_type = request.auth["user_type"] user_id = request.auth["id"] diff --git a/api/leaderboard/leaderboard_view.py b/api/leaderboard/leaderboard_view.py index 0b18b72ec..29745141a 100644 --- a/api/leaderboard/leaderboard_view.py +++ b/api/leaderboard/leaderboard_view.py @@ -1,16 +1,26 @@ -from django.db.models import Sum, F, Value, Count, Q, Prefetch +from django.core.files.storage import FileSystemStorage +from django.db.models import Sum, F, Value, Count, Q, Prefetch, Subquery, OuterRef, IntegerField from django.db.models.functions import Concat, Coalesce from rest_framework.views import APIView +from decouple import config as decouple_config from . import serializers -from db.task import TaskList +from db.task import TaskList, InterestGroup from db.organization import Organization, UserOrganizationLink -from db.user import User, UserRoleLink +from db.user import User, UserRoleLink, UserMentor +from db.mentor import MentorshipSession, MentorshipSessionUserLink from utils.response import CustomResponse from utils.types import OrganizationType, RoleType from utils.utils import DateTimeUtils +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s class StudentsLeaderboard(APIView): + @extend_schema( + tags=['Leaderboard'], + description="Retrieve Students Leaderboard.", + responses={200: serializers.StudentLeaderboardSerializer}, + ) def get(self, request): students_leaderboard = ( User.objects.filter( @@ -41,6 +51,19 @@ def get(self, request): class StudentsMonthlyLeaderboard(APIView): + @extend_schema(tags=['Leaderboard'], description="Retrieve Students Monthly Leaderboard.", + responses={200: inline_serializer( + name='LeaderboardStudentsMonthlyItem', + fields={ + 'muid': s.CharField(), + 'full_name': s.CharField(), + 'total_karma': s.IntegerField(), + 'institution': s.CharField(allow_null=True), + 'profile_pic': s.CharField(allow_null=True), + }, + many=True, + )}, + ) def get(self, request): start_date, end_date = DateTimeUtils.get_start_and_end_of_previous_month() print("REquest reeceivd") @@ -71,6 +94,8 @@ def get(self, request): ), ) .values( + "id", + "muid", "full_name", "total_karma", "institution", @@ -78,12 +103,37 @@ def get(self, request): .order_by("-total_karma")[:20] ) + fs = FileSystemStorage() + result = [] + for student in student_monthly_leaderboard: + user_id = student.pop("id") + path = f"user/profile/{user_id}.png" + student["profile_pic"] = ( + f"{decouple_config('BE_DOMAIN_NAME')}{fs.url(path)}" + if fs.exists(path) + else None + ) + result.append(student) + return CustomResponse( - response=student_monthly_leaderboard + response=result ).get_success_response() class CollegeLeaderboard(APIView): + @extend_schema(tags=['Leaderboard'], description="Retrieve College Leaderboard.", + responses={200: inline_serializer( + name='LeaderboardCollegeItem', + fields={ + 'id': s.CharField(), + 'code': s.CharField(), + 'title': s.CharField(), + 'total_students': s.IntegerField(), + 'total_karma': s.IntegerField(), + }, + many=True, + )}, + ) def get(self, request): college_leaderboard = ( Organization.objects.filter( @@ -96,7 +146,7 @@ def get(self, request): total_students=Count("user_organization_link_org__user"), total_karma=Sum("user_organization_link_org__user__wallet_user__karma"), ) - .values("code", "title", "total_students", "total_karma") + .values("id", "code", "title", "total_students", "total_karma") .order_by("-total_karma")[:20] ) @@ -104,6 +154,18 @@ def get(self, request): class CollegeMonthlyLeaderboard(APIView): + @extend_schema(tags=['Leaderboard'], description="Retrieve College Monthly Leaderboard.", + responses={200: inline_serializer( + name='LeaderboardCollegeMonthlyItem', + fields={ + 'id': s.CharField(), + 'code': s.CharField(), + 'total_karma': s.IntegerField(), + 'students': s.IntegerField(), + }, + many=True, + )}, + ) def get(self, request): start_date, end_date = DateTimeUtils.get_start_and_end_of_previous_month() college_monthly_leaderboard = ( @@ -131,7 +193,7 @@ def get(self, request): students=Count("user_organization_link_org__user", distinct=True), institution=F("title"), ) - .values("code", "total_karma", "students") + .values("id", "code", "total_karma", "students") .order_by("-total_karma")[:20] ) @@ -140,6 +202,11 @@ def get(self, request): ).get_success_response() class WadhwaniCollegeLeaderboard(APIView): + @extend_schema( + tags=['Leaderboard'], + description="Retrieve Wadhwani College Leaderboard.", + responses={200: serializers.WadhwaniCollegeLeaderboardSerializer}, + ) def get(self, request): wadhwani_hashtags = [ "#lp24-interpersonalskills", @@ -188,6 +255,11 @@ def get(self, request): class WadhwaniZonalLeaderboard(APIView): + @extend_schema( + tags=['Leaderboard'], + description="Retrieve Wadhwani Zonal Leaderboard.", + responses={200: serializers.WadhwaniZoneLeaderboardSerializer}, + ) def get(self, request): wadhwani_hashtags = [ "#lp24-interpersonalskills", @@ -234,4 +306,118 @@ def get(self, request): ) response_data = serializers.WadhwaniZoneLeaderboardSerializer(zone_leaderboard, many=True).data - return CustomResponse(response=response_data).get_success_response() \ No newline at end of file + return CustomResponse(response=response_data).get_success_response() + + +class IGMentorLeaderboard(APIView): + @extend_schema( + tags=['Leaderboard'], + description=( + "Retrieve the mentor leaderboard for a specific Interest Group. " + "Mentors are ranked primarily by the number of COMPLETED sessions in that IG, " + "with total karma as a tiebreaker." + ), + responses={200: serializers.IGMentorLeaderboardSerializer(many=True)}, + ) + def get(self, request, ig_id): + ig = InterestGroup.objects.filter(id=ig_id).first() + if not ig: + return CustomResponse(general_message="Interest Group not found").get_failure_response() + + # Sessions completed in this IG by the mentor (linked via MentorshipSessionUserLink) + completed_sessions_subquery = ( + MentorshipSessionUserLink.objects.filter( + user_id=OuterRef('user_id'), + participant_role=MentorshipSessionUserLink.ParticipantRole.MENTOR, + session__session_type=MentorshipSession.SessionType.IG_SESSION, + session__entity_id=ig_id, + session__status=MentorshipSession.Status.COMPLETED, + ) + .values('user_id') + .annotate(cnt=Count('id')) + .values('cnt') + ) + + mentor_qs = ( + UserMentor.objects.filter( + mentor_tier=UserMentor.MentorTier.IG_MENTOR, + status=UserMentor.Status.APPROVED, + user__user_ig_link_user__ig=ig, + user__user_ig_link_user__assignment_type='MENTOR', + user__user_ig_link_user__is_active=True, + ) + .select_related('user__wallet_user', 'org') + .annotate( + total_karma=Coalesce(F('user__wallet_user__karma'), Value(0)), + completed_sessions=Coalesce( + Subquery(completed_sessions_subquery, output_field=IntegerField()), + Value(0), + ), + ) + .distinct() + .order_by('-completed_sessions', '-total_karma') + ) + + rankings = {mentor.id: idx + 1 for idx, mentor in enumerate(mentor_qs)} + serialized = serializers.IGMentorLeaderboardSerializer( + mentor_qs, + many=True, + context={'ig': ig, 'rankings': rankings}, + ) + return CustomResponse(response=serialized.data).get_success_response() + + +class CampusMentorLeaderboard(APIView): + @extend_schema( + tags=['Leaderboard'], + description=( + "Retrieve the mentor leaderboard for a specific campus. " + "Mentors are ranked primarily by the number of COMPLETED campus sessions, " + "with total karma as a tiebreaker." + ), + responses={200: serializers.CampusMentorLeaderboardSerializer(many=True)}, + ) + def get(self, request, campus_id): + campus = Organization.objects.filter( + id=campus_id, org_type=OrganizationType.COLLEGE.value + ).first() + if not campus: + return CustomResponse(general_message="Campus not found").get_failure_response() + + completed_sessions_subquery = ( + MentorshipSessionUserLink.objects.filter( + user_id=OuterRef('user_id'), + participant_role=MentorshipSessionUserLink.ParticipantRole.MENTOR, + session__session_type=MentorshipSession.SessionType.CAMPUS_SESSION, + session__entity_id=campus_id, + session__status=MentorshipSession.Status.COMPLETED, + ) + .values('user_id') + .annotate(cnt=Count('id')) + .values('cnt') + ) + + mentor_qs = ( + UserMentor.objects.filter( + mentor_tier=UserMentor.MentorTier.CAMPUS_MENTOR, + status=UserMentor.Status.APPROVED, + org=campus, + ) + .select_related('user__wallet_user', 'org') + .annotate( + total_karma=Coalesce(F('user__wallet_user__karma'), Value(0)), + completed_sessions=Coalesce( + Subquery(completed_sessions_subquery, output_field=IntegerField()), + Value(0), + ), + ) + .order_by('-completed_sessions', '-total_karma') + ) + + rankings = {mentor.id: idx + 1 for idx, mentor in enumerate(mentor_qs)} + serialized = serializers.CampusMentorLeaderboardSerializer( + mentor_qs, + many=True, + context={'rankings': rankings}, + ) + return CustomResponse(response=serialized.data).get_success_response() \ No newline at end of file diff --git a/api/leaderboard/serializers.py b/api/leaderboard/serializers.py index f364fc450..b6743ec73 100644 --- a/api/leaderboard/serializers.py +++ b/api/leaderboard/serializers.py @@ -1,5 +1,7 @@ from datetime import timedelta +from db.user import UserMentor + from django.db.models import Sum from rest_framework import serializers @@ -80,13 +82,17 @@ class StudentLeaderboardSerializer(serializers.ModelSerializer): institution = serializers.SerializerMethodField() total_karma = serializers.IntegerField(source="wallet_user.karma", default=0) full_name = serializers.CharField() + profile_pic = serializers.SerializerMethodField() def get_institution(self, user): return user.colleges[0].org.title if user.colleges else None + def get_profile_pic(self, user): + return str(user.profile_pic) if user.profile_pic else None + class Meta: model = User - fields = ["full_name", "total_karma", "institution"] + fields = ["muid", "full_name", "total_karma", "institution", "profile_pic"] class WadhwaniCollegeLeaderboardSerializer(serializers.Serializer): code = serializers.CharField() @@ -98,3 +104,42 @@ class WadhwaniZoneLeaderboardSerializer(serializers.Serializer): zone_name = serializers.CharField() total_karma = serializers.IntegerField() students = serializers.IntegerField() + + +class IGMentorLeaderboardSerializer(serializers.Serializer): + mentor_id = serializers.CharField(source='user.id') + mentor_name = serializers.CharField(source='user.full_name') + profile_pic = serializers.SerializerMethodField() + ig_name = serializers.SerializerMethodField() + total_karma = serializers.IntegerField() + completed_sessions = serializers.IntegerField() + rank = serializers.SerializerMethodField() + + def get_profile_pic(self, obj): + return str(obj.user.profile_pic) if obj.user.profile_pic else None + + def get_ig_name(self, obj): + ig = self.context.get('ig') + return ig.name if ig else None + + def get_rank(self, obj): + return self.context.get('rankings', {}).get(obj.id, None) + + +class CampusMentorLeaderboardSerializer(serializers.Serializer): + mentor_id = serializers.CharField(source='user.id') + mentor_name = serializers.CharField(source='user.full_name') + profile_pic = serializers.SerializerMethodField() + campus_name = serializers.SerializerMethodField() + total_karma = serializers.IntegerField() + completed_sessions = serializers.IntegerField() + rank = serializers.SerializerMethodField() + + def get_profile_pic(self, obj): + return str(obj.user.profile_pic) if obj.user.profile_pic else None + + def get_campus_name(self, obj): + return obj.org.title if obj.org else None + + def get_rank(self, obj): + return self.context.get('rankings', {}).get(obj.id, None) diff --git a/api/leaderboard/urls.py b/api/leaderboard/urls.py index c6af1d618..701bf9e71 100644 --- a/api/leaderboard/urls.py +++ b/api/leaderboard/urls.py @@ -9,4 +9,6 @@ path('college-monthly/', leaderboard_view.CollegeMonthlyLeaderboard.as_view()), path('wadhwani-college/', leaderboard_view.WadhwaniCollegeLeaderboard.as_view()), path('wadhwani-zonal/', leaderboard_view.WadhwaniZonalLeaderboard.as_view()), + path('ig-mentor//', leaderboard_view.IGMentorLeaderboard.as_view()), + path('campus-mentor//', leaderboard_view.CampusMentorLeaderboard.as_view()), ] diff --git a/api/management/commands/transition_event_statuses.py b/api/management/commands/transition_event_statuses.py new file mode 100644 index 000000000..cfbac99ef --- /dev/null +++ b/api/management/commands/transition_event_statuses.py @@ -0,0 +1,32 @@ +from django.core.management.base import BaseCommand +from django.db.models.functions import Now +from db.events import Event + + +class Command(BaseCommand): + help = "Bulk transitions event statuses based on their start and end datetimes." + + def handle(self, *args, **options): + self.stdout.write(self.style.NOTICE("Starting event status transitions...")) + + # Transition published -> ongoing (event has started) + ongoing_updated = Event.objects.filter( + status=Event.Status.PUBLISHED, + start_datetime__lte=Now(), + deleted_at__isnull=True, + ).update(status=Event.Status.ONGOING, updated_at=Now()) + + if ongoing_updated > 0: + self.stdout.write(self.style.SUCCESS(f"Transitioned {ongoing_updated} published events to ongoing.")) + + # Transition ongoing -> completed (event has ended) + completed_updated = Event.objects.filter( + status=Event.Status.ONGOING, + end_datetime__lte=Now(), + deleted_at__isnull=True, + ).update(status=Event.Status.COMPLETED, updated_at=Now()) + + if completed_updated > 0: + self.stdout.write(self.style.SUCCESS(f"Transitioned {completed_updated} ongoing events to completed.")) + + self.stdout.write(self.style.SUCCESS("Event status transitions completed.")) diff --git a/api/muComics/chapter/__init__.py b/api/muComics/chapter/__init__.py new file mode 100644 index 000000000..58161eb01 --- /dev/null +++ b/api/muComics/chapter/__init__.py @@ -0,0 +1 @@ +# Initialize the chapter API package. diff --git a/api/muComics/chapter/chapter_views.py b/api/muComics/chapter/chapter_views.py new file mode 100644 index 000000000..692f8626f --- /dev/null +++ b/api/muComics/chapter/chapter_views.py @@ -0,0 +1,794 @@ +import os +import uuid + +from decouple import config as decouple_config +from django.core.files.storage import FileSystemStorage +from django.utils import timezone +from django.db import transaction +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s + +from db.comic import Comic, Chapter, ChapterPage +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.utils import CommonUtils + +from api.muComics.comic.comic_views import can_edit_comic +from .serializers import ( + ChapterListSerializer, + ChapterDetailSerializer, + ChapterWriteSerializer, + ChapterPageSerializer, +) + + +def check_chapter_archived(chapter): + """ + Helper function to check if a chapter is archived. + If it is archived, returns a CustomResponse failure response. + Otherwise, returns None. + """ + if chapter.status == Comic.Status.ARCHIVED: + return CustomResponse( + general_message='Archived chapters cannot be edited.' + ).get_failure_response() + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# CHAPTER LIST + CREATE +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterListCreateView(APIView): + """ + GET /muComics/chapters/?comic_id= → List chapters of a comic + POST /muComics/chapters/ → Create a chapter + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Chapters'], + description="List all active (non-deleted) chapters for a comic. Supports status filter and search by title.", + responses={200: ChapterListSerializer(many=True)}, + ) + def get(self, request): + comic_id = request.query_params.get('comic_id') + if not comic_id: + return CustomResponse( + general_message='comic_id query parameter is required.' + ).get_failure_response() + + # Check if comic exists and is active + comic = Comic.objects.filter(id=comic_id, deleted_at__isnull=True).first() + if not comic: + return CustomResponse( + general_message='Comic not found.' + ).get_failure_response(status_code=404, http_status_code=404) + + queryset = Chapter.objects.filter(comic_id=comic_id, deleted_at__isnull=True) + + # Optional status filter: ?status=draft|published|archived + if status_filter := request.query_params.get('status'): + if status_filter not in Comic.Status.values: + return CustomResponse( + general_message=f'Invalid status. Allowed: {", ".join(Comic.Status.values)}.' + ).get_failure_response() + queryset = queryset.filter(status=status_filter) + + paginated = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=['title'], + sort_fields={ + 'chapter_number': 'chapter_number', + 'created_at': 'created_at', + }, + ) + + serializer = ChapterListSerializer(paginated['queryset'], many=True, context={'request': request}) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + @extend_schema( + tags=['muComics - Chapters'], + description="Create a new chapter inside a comic. Only creator or editors of the comic can create.", + request=ChapterWriteSerializer, + responses={200: ChapterDetailSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + comic_id = request.data.get('comic') + + if not comic_id: + return CustomResponse( + general_message='comic is required.' + ).get_failure_response() + + comic = Comic.objects.filter(id=comic_id, deleted_at__isnull=True).first() + if not comic: + return CustomResponse( + general_message='Comic not found.' + ).get_failure_response(status_code=404, http_status_code=404) + + # Permission: creator or assigned editor of the comic + if not can_edit_comic(user_id, comic): + return CustomResponse( + general_message='You do not have permission to add a chapter to this comic.' + ).get_unauthorized_response() + + serializer = ChapterWriteSerializer( + data=request.data, + context={'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + chapter = serializer.save() + return CustomResponse( + general_message=f'Chapter "{chapter.title}" created successfully.', + response=ChapterDetailSerializer(chapter, context={'request': request}).data, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# CHAPTER DETAIL — GET / PATCH / DELETE +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterDetailView(APIView): + """ + GET /muComics/chapters// → detail + PATCH /muComics/chapters// → update + DELETE /muComics/chapters// → soft delete (comic creator only) + """ + authentication_classes = [CustomizePermission] + + def _get_chapter_or_error(self, chapter_id): + chapter = Chapter.objects.filter(id=chapter_id, deleted_at__isnull=True).select_related('comic', 'created_by', 'updated_by').first() + if not chapter: + return None, 'Chapter not found.' + return chapter, None + + @extend_schema( + tags=['muComics - Chapters'], + description="Retrieve full detail of a chapter including pages.", + responses={200: ChapterDetailSerializer}, + ) + def get(self, request, chapter_id): + chapter, error = self._get_chapter_or_error(chapter_id) + if error: + return CustomResponse(general_message=error).get_failure_response(status_code=404, http_status_code=404) + + return CustomResponse( + general_message=f'Chapter "{chapter.title}" retrieved successfully.', + response=ChapterDetailSerializer(chapter, context={'request': request}).data, + ).get_success_response() + + @extend_schema( + tags=['muComics - Chapters'], + description="Update chapter details. Requires comic creator or editor permissions. Archived chapters cannot be edited.", + request=ChapterWriteSerializer, + responses={200: ChapterDetailSerializer}, + ) + def patch(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + chapter, error = self._get_chapter_or_error(chapter_id) + if error: + return CustomResponse(general_message=error).get_failure_response(status_code=404, http_status_code=404) + + # Permission: creator or assigned editor of the comic + if not can_edit_comic(user_id, chapter.comic): + return CustomResponse( + general_message='You do not have permission to edit this chapter.' + ).get_unauthorized_response() + + # Archived chapters cannot be edited + if error_response := check_chapter_archived(chapter): + return error_response + + serializer = ChapterWriteSerializer( + chapter, data=request.data, + partial=True, + context={'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + chapter = serializer.save() + return CustomResponse( + general_message=f'Chapter "{chapter.title}" updated successfully.', + response=ChapterDetailSerializer(chapter, context={'request': request}).data, + ).get_success_response() + + @extend_schema( + tags=['muComics - Chapters'], + description="Soft delete a chapter. Only the comic creator can delete a chapter.", + responses={200: inline_serializer( + name='ChapterDeleteResponse', + fields={'id': s.CharField()}, + )}, + ) + def delete(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + chapter, error = self._get_chapter_or_error(chapter_id) + if error: + return CustomResponse(general_message=error).get_failure_response(status_code=404, http_status_code=404) + + # Only the comic creator can delete a chapter + if chapter.comic.created_by_id != user_id: + return CustomResponse( + general_message='Only the comic creator can delete this chapter.' + ).get_unauthorized_response() + + now = timezone.now() + with transaction.atomic(): + chapter.deleted_at = now + chapter.deleted_by_id = user_id + chapter.updated_by_id = user_id + chapter.updated_at = now + chapter.save(update_fields=['deleted_at', 'deleted_by', 'updated_by', 'updated_at']) + + # Soft delete all linked pages too + ChapterPage.objects.filter(chapter=chapter, deleted_at__isnull=True).update( + deleted_at=now, + deleted_by_id=user_id, + updated_by_id=user_id, + updated_at=now + ) + + return CustomResponse( + general_message=f'Chapter "{chapter.title}" deleted successfully.', + response={'id': chapter.id}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# CHAPTER STATUS ACTIONS (PUBLISH / ARCHIVE) +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterPublishView(APIView): + """ + POST /muComics/chapters//publish/ → Transition chapter status draft → published + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Chapters'], + description="Publish a chapter. Comic creator only.", + responses={200: inline_serializer( + name='ChapterPublishResponse', + fields={'id': s.CharField(), 'status': s.CharField()}, + )}, + ) + def post(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + chapter = Chapter.objects.filter(id=chapter_id, deleted_at__isnull=True).select_related('comic').first() + if not chapter: + return CustomResponse(general_message='Chapter not found.').get_failure_response(status_code=404, http_status_code=404) + + # Only comic creator can publish + if chapter.comic.created_by_id != user_id: + return CustomResponse( + general_message='Only the comic creator can publish this chapter.' + ).get_unauthorized_response() + + if chapter.status == Comic.Status.PUBLISHED: + return CustomResponse(general_message='Chapter is already published.').get_failure_response() + + if chapter.status == Comic.Status.ARCHIVED: + return CustomResponse(general_message='Archived chapters cannot be published.').get_failure_response() + + now = timezone.now() + chapter.status = Comic.Status.PUBLISHED + chapter.published_at = now + chapter.updated_by_id = user_id + chapter.updated_at = now + chapter.save(update_fields=['status', 'published_at', 'updated_by', 'updated_at']) + + return CustomResponse( + general_message=f'Chapter "{chapter.title}" published successfully.', + response={'id': chapter.id, 'status': Comic.Status.PUBLISHED}, + ).get_success_response() + + +class ChapterArchiveView(APIView): + """ + POST /muComics/chapters//archive/ → Transition chapter status published/draft → archived + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Chapters'], + description="Archive a chapter. Comic creator only.", + responses={200: inline_serializer( + name='ChapterArchiveResponse', + fields={'id': s.CharField(), 'status': s.CharField()}, + )}, + ) + def post(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + chapter = Chapter.objects.filter(id=chapter_id, deleted_at__isnull=True).select_related('comic').first() + if not chapter: + return CustomResponse(general_message='Chapter not found.').get_failure_response(status_code=404, http_status_code=404) + + # Only comic creator can archive + if chapter.comic.created_by_id != user_id: + return CustomResponse( + general_message='Only the comic creator can archive this chapter.' + ).get_unauthorized_response() + + if chapter.status == Comic.Status.ARCHIVED: + return CustomResponse(general_message='Chapter is already archived.').get_failure_response() + + now = timezone.now() + chapter.status = Comic.Status.ARCHIVED + chapter.updated_by_id = user_id + chapter.updated_at = now + chapter.save(update_fields=['status', 'updated_by', 'updated_at']) + + return CustomResponse( + general_message=f'Chapter "{chapter.title}" archived successfully.', + response={'id': chapter.id, 'status': Comic.Status.ARCHIVED}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# PAGE LIST + ADD (CREATE) +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterPageListCreateAPI(APIView): + """ + GET /muComics/chapters//pages/ → List pages of a chapter + POST /muComics/chapters//pages/ → Add a single page to a chapter + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Pages'], + description="List all active pages of a chapter, ordered by page number.", + responses={200: ChapterPageSerializer(many=True)}, + ) + def get(self, request, chapter_id): + chapter = Chapter.objects.filter(id=chapter_id, deleted_at__isnull=True).first() + if not chapter: + return CustomResponse(general_message='Chapter not found.').get_failure_response(status_code=404, http_status_code=404) + + pages = ChapterPage.objects.filter(chapter_id=chapter_id, deleted_at__isnull=True) + + paginated = CommonUtils.get_paginated_queryset( + pages, + request, + search_fields=[], + sort_fields={ + 'page_number': 'page_number', + 'created_at': 'created_at', + }, + ) + + serializer = ChapterPageSerializer(paginated['queryset'], many=True, context={'request': request}) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + @extend_schema( + tags=['muComics - Pages'], + description="Add a single page to a chapter. Comic creators or editors only.", + request=ChapterPageSerializer, + responses={200: ChapterPageSerializer}, + ) + def post(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + chapter = Chapter.objects.filter(id=chapter_id, deleted_at__isnull=True).select_related('comic').first() + if not chapter: + return CustomResponse(general_message='Chapter not found.').get_failure_response(status_code=404, http_status_code=404) + + # Archived chapters cannot be edited + if error_response := check_chapter_archived(chapter): + return error_response + + # Permission: creator or assigned editor of the comic + if not can_edit_comic(user_id, chapter.comic): + return CustomResponse( + general_message='You do not have permission to add a page to this chapter.' + ).get_unauthorized_response() + + # Inject the chapter into request data + data = request.data.copy() + data['chapter'] = chapter_id + + serializer = ChapterPageSerializer(data=data) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + now = timezone.now() + page = serializer.save( + id=str(uuid.uuid4()), + created_by_id=user_id, + updated_by_id=user_id, + created_at=now, + updated_at=now, + ) + + return CustomResponse( + general_message='Page added successfully.', + response=ChapterPageSerializer(page, context={'request': request}).data, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# PAGE DETAIL (UPDATE / DELETE) +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterPageDetailAPI(APIView): + """ + PATCH /muComics/pages// → Update page details + DELETE /muComics/pages// → Delete page (soft delete) + """ + authentication_classes = [CustomizePermission] + + def _get_page_or_error(self, page_id): + page = ChapterPage.objects.filter(id=page_id, deleted_at__isnull=True).select_related('chapter__comic').first() + if not page: + return None, 'Page not found.' + return page, None + + @extend_schema( + tags=['muComics - Pages'], + description="Update page information (e.g. page_number or image_key). Comic creators or editors only.", + request=ChapterPageSerializer, + responses={200: ChapterPageSerializer}, + ) + def patch(self, request, page_id): + user_id = JWTUtils.fetch_user_id(request) + page, error = self._get_page_or_error(page_id) + if error: + return CustomResponse(general_message=error).get_failure_response(status_code=404, http_status_code=404) + + # Archived chapters cannot be edited + if error_response := check_chapter_archived(page.chapter): + return error_response + + # Permission: creator or assigned editor of the comic + if not can_edit_comic(user_id, page.chapter.comic): + return CustomResponse( + general_message='You do not have permission to update this page.' + ).get_unauthorized_response() + + serializer = ChapterPageSerializer( + page, data=request.data, + partial=True, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + now = timezone.now() + page = serializer.save( + updated_by_id=user_id, + updated_at=now, + ) + + return CustomResponse( + general_message='Page updated successfully.', + response=ChapterPageSerializer(page, context={'request': request}).data, + ).get_success_response() + + @extend_schema( + tags=['muComics - Pages'], + description="Delete a page (soft delete). Comic creators or editors only.", + responses={200: inline_serializer( + name='PageDeleteResponse', + fields={'id': s.CharField()}, + )}, + ) + def delete(self, request, page_id): + user_id = JWTUtils.fetch_user_id(request) + page, error = self._get_page_or_error(page_id) + if error: + return CustomResponse(general_message=error).get_failure_response(status_code=404, http_status_code=404) + + # Archived chapters cannot be edited + if error_response := check_chapter_archived(page.chapter): + return error_response + + # Permission: creator or assigned editor of the comic + if not can_edit_comic(user_id, page.chapter.comic): + return CustomResponse( + general_message='You do not have permission to delete this page.' + ).get_unauthorized_response() + + now = timezone.now() + page.deleted_at = now + page.deleted_by_id = user_id + page.updated_by_id = user_id + page.updated_at = now + page.save(update_fields=['deleted_at', 'deleted_by', 'updated_by', 'updated_at']) + + return CustomResponse( + general_message='Page deleted successfully.', + response={'id': page.id}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# PAGE REORDER (BULK UPDATE) +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterPageReorderAPI(APIView): + """ + POST /muComics/chapters//pages/reorder/ → Bulk update page order + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Pages'], + description="Bulk reorder page numbers in a chapter. Comic creators or editors only.", + request=inline_serializer( + name='PageReorderRequest', + fields={ + 'page_orders': s.ListField( + child=inline_serializer( + name='PageOrderItem', + fields={ + 'id': s.CharField(), + 'page_number': s.IntegerField(), + } + ) + ) + } + ), + responses={200: inline_serializer( + name='PageReorderResponse', + fields={'success': s.BooleanField()}, + )}, + ) + def post(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + chapter = Chapter.objects.filter(id=chapter_id, deleted_at__isnull=True).select_related('comic').first() + if not chapter: + return CustomResponse(general_message='Chapter not found.').get_failure_response(status_code=404, http_status_code=404) + + # Archived chapters cannot be edited + if error_response := check_chapter_archived(chapter): + return error_response + + # Permission: creator or assigned editor of the comic + if not can_edit_comic(user_id, chapter.comic): + return CustomResponse( + general_message='You do not have permission to reorder pages.' + ).get_unauthorized_response() + + page_orders = request.data.get('page_orders', []) + if not isinstance(page_orders, list) or not page_orders: + return CustomResponse( + general_message='page_orders must be a non-empty list of page ordering objects.' + ).get_failure_response() + + # Validate duplicate page numbers or duplicate IDs in request + for item in page_orders: + if not isinstance(item, dict) or 'id' not in item or 'page_number' not in item: + return CustomResponse( + general_message='Each page order item must be an object containing both "id" and "page_number".' + ).get_failure_response() + + ids = [item['id'] for item in page_orders] + numbers = [item['page_number'] for item in page_orders] + + + if len(ids) != len(set(ids)): + return CustomResponse(general_message='Duplicate page IDs in request.').get_failure_response() + if len(numbers) != len(set(numbers)): + return CustomResponse(general_message='Duplicate page numbers in request.').get_failure_response() + + # Check positive page numbers + if any(num < 1 for num in numbers): + return CustomResponse(general_message='Page numbers must be positive integers starting from 1.').get_failure_response() + + # Load all pages in chapter to verify IDs belong to this chapter + pages_qs = ChapterPage.objects.filter(chapter_id=chapter_id, deleted_at__isnull=True) + pages_dict = {str(page.id): page for page in pages_qs} + + # Verify all input IDs belong to the chapter + for page_id in ids: + if page_id not in pages_dict: + return CustomResponse( + general_message=f'Page ID {page_id} does not exist in this chapter.' + ).get_failure_response(status_code=404, http_status_code=404) + + # Verify all pages in DB are accounted for in the reorder request + if len(ids) != len(pages_dict): + return CustomResponse( + general_message='All active pages in the chapter must be included in the reorder request.' + ).get_failure_response() + + now = timezone.now() + updated_pages = [] + for item in page_orders: + page_id = item['id'] + new_num = item['page_number'] + page = pages_dict[page_id] + page.page_number = new_num + page.updated_by_id = user_id + page.updated_at = now + updated_pages.append(page) + + with transaction.atomic(): + ChapterPage.objects.bulk_update( + updated_pages, + fields=['page_number', 'updated_by', 'updated_at'] + ) + + return CustomResponse( + general_message='Pages reordered successfully.', + response={'success': True}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# UPLOAD APIS (PRESIGNED UPLOAD URL & REGISTER PAGES) +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterUploadURLAPI(APIView): + """ + POST /muComics/chapters/upload-url/ → Direct file upload (images and PDFs) to local storage. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Uploads'], + description="Upload a media file (Image or PDF) to local storage.", + request={ + 'multipart/form-data': inline_serializer( + name='ChapterUploadRequest', + fields={ + 'file': s.FileField(), + } + ) + }, + responses={200: inline_serializer( + name='UploadURLResponse', + fields={ + 'upload_url': s.CharField(), + 'image_key': s.CharField(), + } + )}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + file_obj = request.FILES.get('file') + + if file_obj is None: + return CustomResponse( + general_message='No file provided.' + ).get_failure_response() + + content_type = file_obj.content_type or '' + file_name = file_obj.name or '' + ext = os.path.splitext(file_name)[1].lower() + + is_image = content_type.startswith("image/") or ext in ['.jpg', '.jpeg', '.png', '.webp', '.gif'] + is_pdf = content_type == "application/pdf" or ext == ".pdf" + + if not (is_image or is_pdf): + return CustomResponse( + general_message='Unsupported file type. Only images and PDFs are allowed.' + ).get_failure_response() + + if is_image: + if file_obj.size > 5 * 1024 * 1024: + return CustomResponse( + general_message='Image file must be under 5 MB.' + ).get_failure_response() + try: + from PIL import Image + img = Image.open(file_obj) + img.verify() + file_obj.seek(0) + except Exception: + return CustomResponse( + general_message='Invalid or corrupted image file.' + ).get_failure_response() + + elif is_pdf: + if file_obj.size > 20 * 1024 * 1024: + return CustomResponse( + general_message='PDF file must be under 20 MB.' + ).get_failure_response() + + fs = FileSystemStorage() + unique_id = uuid.uuid4() + image_key = f"comics/chapters/{unique_id}{ext}" + + fs.save(image_key, file_obj) + + be_domain = decouple_config("BE_DOMAIN_NAME") + upload_url = f"{be_domain}{fs.url(image_key)}" + + return CustomResponse( + general_message='File uploaded successfully.', + response={ + 'upload_url': upload_url, + 'image_key': image_key, + } + ).get_success_response() + + +class ChapterRegisterImagesAPI(APIView): + """ + POST /muComics/chapters//pages/register/ → Register uploaded local media paths as chapter pages + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Uploads'], + description="Register multiple uploaded local media paths as sequential pages inside a chapter. Creator/editors only.", + request=inline_serializer( + name='RegisterImagesRequest', + fields={ + 'image_keys': s.ListField(child=s.CharField()) + } + ), + responses={200: ChapterPageSerializer(many=True)}, + ) + def post(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + chapter = Chapter.objects.filter(id=chapter_id, deleted_at__isnull=True).select_related('comic').first() + if not chapter: + return CustomResponse(general_message='Chapter not found.').get_failure_response(status_code=404, http_status_code=404) + + # Archived chapters cannot be edited + if error_response := check_chapter_archived(chapter): + return error_response + + # Permission: creator or assigned editor of the comic + if not can_edit_comic(user_id, chapter.comic): + return CustomResponse( + general_message='You do not have permission to register pages in this chapter.' + ).get_unauthorized_response() + + image_keys = request.data.get('image_keys', []) + if not isinstance(image_keys, list) or not image_keys: + return CustomResponse( + general_message='image_keys must be a non-empty list of string paths.' + ).get_failure_response() + + # Validate string image keys + if not all(isinstance(k, str) and k.strip() for k in image_keys): + return CustomResponse( + general_message='Each image key must be a non-empty string.' + ).get_failure_response() + + now = timezone.now() + with transaction.atomic(): + # Get the current maximum page number in the chapter + max_page = ChapterPage.objects.filter(chapter_id=chapter_id, deleted_at__isnull=True).order_by('-page_number').first() + start_num = max_page.page_number if max_page else 0 + + created_pages = [] + for i, image_key in enumerate(image_keys, start=1): + page = ChapterPage.objects.create( + id=str(uuid.uuid4()), + chapter=chapter, + page_number=start_num + i, + image_key=image_key, + created_by_id=user_id, + updated_by_id=user_id, + created_at=now, + updated_at=now, + ) + created_pages.append(page) + + return CustomResponse( + general_message=f'Registered {len(created_pages)} pages successfully.', + response=ChapterPageSerializer(created_pages, many=True, context={'request': request}).data, + ).get_success_response() diff --git a/api/muComics/chapter/serializers.py b/api/muComics/chapter/serializers.py new file mode 100644 index 000000000..4916103f9 --- /dev/null +++ b/api/muComics/chapter/serializers.py @@ -0,0 +1,233 @@ +import uuid + +from django.utils.text import slugify +from django.utils import timezone +from rest_framework import serializers + +from db.comic import Chapter, ChapterPage, Comic +from api.muComics.comic.serializers import MinimalUserSerializer +from api.dashboard.media_content.image_utils import resolve_image_url + + +# ───────────────────────────────────────────────────────────────────────────── +# CHAPTER PAGE SERIALIZER +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterPageSerializer(serializers.ModelSerializer): + """ + Serializer representing individual pages in a chapter. + """ + class Meta: + model = ChapterPage + fields = ['id', 'chapter', 'page_number', 'image_key', 'created_at'] + read_only_fields = ['id', 'created_at'] + + def to_representation(self, instance): + data = super().to_representation(instance) + if data.get('image_key'): + data['image_key'] = resolve_image_url(data['image_key'], self.context.get('request')) + return data + + def validate_page_number(self, value): + if value < 1: + raise serializers.ValidationError("Page number must be a positive integer starting from 1.") + return value + + def validate_image_key(self, value): + value = value.strip() if value else "" + if not value: + raise serializers.ValidationError("Image key must not be blank.") + return value + + def validate(self, attrs): + chapter = attrs.get('chapter') if 'chapter' in attrs else (self.instance.chapter if self.instance else None) + page_number = attrs.get('page_number') if 'page_number' in attrs else (self.instance.page_number if self.instance else None) + + if chapter and page_number is not None: + # Check unique constraint on (chapter, page_number) excluding deleted pages + qs = ChapterPage.objects.filter(chapter=chapter, page_number=page_number, deleted_at__isnull=True) + if self.instance: + qs = qs.exclude(id=self.instance.id) + if qs.exists(): + raise serializers.ValidationError({ + 'page_number': 'A page with this number already exists in this chapter.' + }) + return attrs + + +# ───────────────────────────────────────────────────────────────────────────── +# CHAPTER LIST (lean - for paginated lists/feeds) +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterListSerializer(serializers.ModelSerializer): + """ + Lean serializer for listing chapters (e.g. comic chapter index). + """ + class Meta: + model = Chapter + fields = [ + 'id', 'comic', 'title', 'slug', 'chapter_number', + 'cover_image_key', 'status', 'published_at', 'created_at', + ] + read_only_fields = fields + + def to_representation(self, instance): + data = super().to_representation(instance) + if data.get('cover_image_key'): + data['cover_image_key'] = resolve_image_url(data['cover_image_key'], self.context.get('request')) + return data + + +# ───────────────────────────────────────────────────────────────────────────── +# CHAPTER DETAIL (full - detail, create, or update responses) +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterDetailSerializer(serializers.ModelSerializer): + """ + Full serializer for chapter retrieval. + """ + created_by = MinimalUserSerializer(read_only=True) + updated_by = MinimalUserSerializer(read_only=True) + pages = serializers.SerializerMethodField() + + class Meta: + model = Chapter + fields = [ + 'id', 'comic', 'title', 'slug', 'description', 'chapter_number', + 'cover_image_key', 'status', 'published_at', + 'pages', + 'created_by', 'created_at', + 'updated_by', 'updated_at', + ] + read_only_fields = fields + + def get_pages(self, obj): + active_pages = obj.pages.filter(deleted_at__isnull=True).order_by('page_number') + return ChapterPageSerializer(active_pages, many=True, context=self.context).data + + def to_representation(self, instance): + data = super().to_representation(instance) + if data.get('cover_image_key'): + data['cover_image_key'] = resolve_image_url(data['cover_image_key'], self.context.get('request')) + return data + + +# ───────────────────────────────────────────────────────────────────────────── +# CHAPTER WRITE (create / update input) +# ───────────────────────────────────────────────────────────────────────────── + +class ChapterWriteSerializer(serializers.ModelSerializer): + """ + Input serializer for POST /chapters/ and PATCH /chapters//. + Caller never directly specifies slug, status, published_at, or audit fields. + """ + class Meta: + model = Chapter + fields = ['comic', 'title', 'description', 'chapter_number', 'cover_image_key'] + extra_kwargs = { + 'title': { + 'required': True, + 'max_length': 150, + }, + 'chapter_number': { + 'required': True, + }, + 'description': { + 'required': False, + 'allow_null': True, + 'allow_blank': True, + }, + 'cover_image_key': { + 'required': False, + 'allow_null': True, + 'allow_blank': True, + 'max_length': 255, + }, + } + + def validate_title(self, value): + if not value or not value.strip(): + raise serializers.ValidationError('Title must not be blank.') + return value.strip() + + def validate_chapter_number(self, value): + if value < 0: + raise serializers.ValidationError('Chapter number must not be negative.') + return value + + def _generate_unique_slug(self, title): + """ + Generates a URL-safe slug from title. + Appends an incrementing counter if the base slug already exists. + Slug is truncated to 70 chars before suffix to stay within max_length=75. + """ + base = slugify(title)[:70] + slug = base + counter = 1 + exclude_id = self.instance.id if self.instance else None + while True: + qs = Chapter.objects.filter(slug=slug) + if exclude_id: + qs = qs.exclude(id=exclude_id) + if not qs.exists(): + break + slug = f'{base}-{counter}' + counter += 1 + return slug + + def validate(self, attrs): + comic = attrs.get('comic') if 'comic' in attrs else (self.instance.comic if self.instance else None) + chapter_number = attrs.get('chapter_number') if 'chapter_number' in attrs else (self.instance.chapter_number if self.instance else None) + + if comic and chapter_number is not None: + # Check unique constraint on (comic, chapter_number) excluding deleted chapters + qs = Chapter.objects.filter(comic=comic, chapter_number=chapter_number, deleted_at__isnull=True) + if self.instance: + qs = qs.exclude(id=self.instance.id) + if qs.exists(): + raise serializers.ValidationError({ + 'chapter_number': 'A chapter with this number already exists for this comic.' + }) + return attrs + + def create(self, validated_data): + user_id = self.context['user_id'] + now = timezone.now() + + validated_data['id'] = str(uuid.uuid4()) + validated_data['slug'] = self._generate_unique_slug(validated_data['title']) + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + validated_data['created_at'] = now + validated_data['updated_at'] = now + return Chapter.objects.create(**validated_data) + + def update(self, instance, validated_data): + user_id = self.context['user_id'] + now = timezone.now() + + # Chapter parent comic cannot be modified on update + if 'comic' in validated_data: + if validated_data['comic'] != instance.comic: + raise serializers.ValidationError({'comic': 'Chapter parent comic cannot be modified.'}) + validated_data.pop('comic') + + # Re-generate slug only when title changes + if 'title' in validated_data and validated_data['title'] != instance.title: + validated_data['slug'] = self._generate_unique_slug(validated_data['title']) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.updated_by_id = user_id + instance.updated_at = now + + # Only write columns this PATCH actually touched. + changed_fields = list(validated_data.keys()) + if 'title' in changed_fields: + changed_fields.append('slug') + changed_fields += ['updated_by', 'updated_at'] + + instance.save(update_fields=changed_fields) + return instance + diff --git a/api/muComics/chapter/urls.py b/api/muComics/chapter/urls.py new file mode 100644 index 000000000..107bba54c --- /dev/null +++ b/api/muComics/chapter/urls.py @@ -0,0 +1,30 @@ +from django.urls import path + +from . import chapter_views + +urlpatterns = [ + # Upload helper API (positioned first to avoid conflict with chapter_id string parameter) + path('upload-url/', chapter_views.ChapterUploadURLAPI.as_view(), name='chapter-upload-url'), + + # Chapters List + Create + path('', chapter_views.ChapterListCreateView.as_view(), name='chapter-list-create'), + + # Chapter Detail + Update + Delete + path('/', chapter_views.ChapterDetailView.as_view(), name='chapter-detail'), + + # Chapter Status actions + path('/publish/', chapter_views.ChapterPublishView.as_view(), name='chapter-publish'), + path('/archive/', chapter_views.ChapterArchiveView.as_view(), name='chapter-archive'), + + # Pages List + Add + path('/pages/', chapter_views.ChapterPageListCreateAPI.as_view(), name='chapter-page-list-create'), + + # Reorder Pages + path('/pages/reorder/', chapter_views.ChapterPageReorderAPI.as_view(), name='chapter-page-reorder'), + + # Register Uploaded Images as Pages + path('/pages/register/', chapter_views.ChapterRegisterImagesAPI.as_view(), name='chapter-page-register-images'), + + # Page Detail (Update / Delete) + path('pages//', chapter_views.ChapterPageDetailAPI.as_view(), name='chapter-page-detail'), +] diff --git a/api/muComics/comic/__init__.py b/api/muComics/comic/__init__.py new file mode 100644 index 000000000..ad2255c02 --- /dev/null +++ b/api/muComics/comic/__init__.py @@ -0,0 +1 @@ +# Comic module diff --git a/api/muComics/comic/admin_views.py b/api/muComics/comic/admin_views.py new file mode 100644 index 000000000..af52097cd --- /dev/null +++ b/api/muComics/comic/admin_views.py @@ -0,0 +1,282 @@ +""" +Genre admin views — embedded in the comic module. + +Endpoints (all authenticated; writes require Admins role): + GET /muComics/comics/genres/ → list (active only for non-admin; admin can filter ?is_active=) + POST /muComics/comics/genres/ → create [Admin] + GET /muComics/comics/genres// → detail (active for non-admin; any for admin) + PATCH /muComics/comics/genres// → update name [Admin] + DELETE /muComics/comics/genres// → soft deactivate (is_active=False) [Admin] + POST /muComics/comics/genres//reinstate/ → reactivate (is_active=True) [Admin] +""" + +from django.utils import timezone + +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s +from rest_framework.views import APIView + +from db.comic import Genre +from utils.permission import CustomizePermission, JWTUtils, RoleRequired +from utils.response import CustomResponse +from utils.types import RoleType +from utils.utils import CommonUtils + +from .serializers import GenreReadSerializer, GenreWriteSerializer + + +# ───────────────────────────────────────────────────────────────────────────── +# HELPERS +# ───────────────────────────────────────────────────────────────────────────── + +def _is_admin(request): + """Return True if the JWT carries the Admins role.""" + return RoleType.ADMIN.value in JWTUtils.fetch_role(request) + + +# ───────────────────────────────────────────────────────────────────────────── +# GENRE LIST + CREATE +# ───────────────────────────────────────────────────────────────────────────── + +class GenreListCreateView(APIView): + """ + GET /muComics/comics/genres/ + Non-admins → only is_active=True genres. + Admins → all genres; optionally filter with ?is_active=true|false. + + POST /muComics/comics/genres/ [Admin only] + Create a new genre (is_active defaults to True). + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Genre'], + description=( + "List genres. Non-admins see only active genres. " + "Admins can filter with ?is_active=true|false. " + "Supports ?search= (by name) and ?sort=name|created_at." + ), + responses={200: GenreReadSerializer(many=True)}, + ) + def get(self, request): + admin = _is_admin(request) + + if admin: + queryset = Genre.objects.all() + is_active_param = request.query_params.get('is_active') + if is_active_param is not None: + if is_active_param.lower() == 'true': + queryset = queryset.filter(is_active=True) + elif is_active_param.lower() == 'false': + queryset = queryset.filter(is_active=False) + else: + return CustomResponse( + general_message='Invalid value for is_active. Use true or false.' + ).get_failure_response() + else: + # Regular users only ever see active genres + queryset = Genre.objects.filter(is_active=True) + + paginated = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=['name'], + sort_fields={ + 'name': 'name', + 'created_at': 'created_at', + }, + ) + + serializer = GenreReadSerializer(paginated['queryset'], many=True) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + @extend_schema( + tags=['muComics - Genre'], + description="Create a new genre. is_active defaults to true. Admin only.", + request=GenreWriteSerializer, + responses={200: GenreReadSerializer}, + ) + @RoleRequired([RoleType.ADMIN]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + + serializer = GenreWriteSerializer( + data=request.data, + context={'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response("") + + genre = serializer.save() + + return CustomResponse( + general_message=f'Genre "{genre.name}" created successfully.', + response=GenreReadSerializer(genre).data, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# GENRE DETAIL — GET / PATCH / DELETE (soft deactivate) +# ───────────────────────────────────────────────────────────────────────────── + +class GenreDetailView(APIView): + """ + GET → active genres only for non-admins; any genre for admins. + PATCH → update name (Admin only). + DELETE → soft deactivate: is_active = False (Admin only). + """ + authentication_classes = [CustomizePermission] + + def _get_genre(self, genre_id, admin): + """ + Fetch genre by id. + - Admin: any genre (active or inactive). + - Non-admin: only active genres. + Returns (genre, error_string). + """ + qs = Genre.objects.all() if admin else Genre.objects.filter(is_active=True) + genre = qs.filter(id=genre_id).first() + if not genre: + return None, 'Genre not found.' + return genre, None + + @extend_schema( + tags=['muComics - Genre'], + description=( + "Retrieve a genre. Non-admins only see active genres (inactive → 404). " + "Admins can retrieve any genre regardless of is_active status." + ), + responses={200: GenreReadSerializer}, + ) + def get(self, request, genre_id): + admin = _is_admin(request) + genre, error = self._get_genre(genre_id, admin) + if error: + return CustomResponse(general_message=error).get_failure_response() + + return CustomResponse( + general_message=f'Genre "{genre.name}" retrieved successfully.', + response=GenreReadSerializer(genre).data, + ).get_success_response() + + @extend_schema( + tags=['muComics - Genre'], + description="Update a genre's name (slug is regenerated automatically). Admin only.", + request=GenreWriteSerializer, + responses={200: GenreReadSerializer}, + ) + @RoleRequired([RoleType.ADMIN]) + def patch(self, request, genre_id): + user_id = JWTUtils.fetch_user_id(request) + # Admin context — can edit active or inactive genre + genre, error = self._get_genre(genre_id, admin=True) + if error: + return CustomResponse(general_message=error).get_failure_response() + + serializer = GenreWriteSerializer( + genre, + data=request.data, + partial=True, + context={'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + genre = serializer.save() + + return CustomResponse( + general_message=f'Genre "{genre.name}" updated successfully.', + response=GenreReadSerializer(genre).data, + ).get_success_response() + + @extend_schema( + tags=['muComics - Genre'], + description=( + "Soft-deactivate a genre (sets is_active = False). " + "The genre row and all comic_genre_link rows are preserved. " + "Admin only." + ), + responses={200: inline_serializer( + name='GenreDeactivateResponse', + fields={ + 'id': s.CharField(), + 'is_active': s.BooleanField(), + }, + )}, + ) + @RoleRequired([RoleType.ADMIN]) + def delete(self, request, genre_id): + user_id = JWTUtils.fetch_user_id(request) + genre, error = self._get_genre(genre_id, admin=True) + if error: + return CustomResponse(general_message=error).get_failure_response() + + if not genre.is_active: + return CustomResponse( + general_message=f'Genre "{genre.name}" is already inactive.' + ).get_failure_response() + + now = timezone.now() + genre.is_active = False + genre.updated_by_id = user_id + genre.updated_at = now + genre.save(update_fields=['is_active', 'updated_by', 'updated_at']) + + return CustomResponse( + general_message=f'Genre "{genre.name}" deactivated successfully.', + response={'id': genre.id, 'is_active': False}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# GENRE REINSTATE +# ───────────────────────────────────────────────────────────────────────────── + +class GenreReinstateView(APIView): + """ + POST /muComics/comics/genres//reinstate/ + Reactivates a deactivated genre (is_active = True). Admin only. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics - Genre'], + description="Reinstate (reactivate) a deactivated genre. Sets is_active = True. Admin only.", + responses={200: inline_serializer( + name='GenreReinstateResponse', + fields={ + 'id': s.CharField(), + 'is_active': s.BooleanField(), + }, + )}, + ) + @RoleRequired([RoleType.ADMIN]) + def post(self, request, genre_id): + user_id = JWTUtils.fetch_user_id(request) + # Admin needed — fetch any genre (active or inactive) + genre = Genre.objects.filter(id=genre_id).first() + if not genre: + return CustomResponse(general_message='Genre not found.').get_failure_response() + + if genre.is_active: + return CustomResponse( + general_message=f'Genre "{genre.name}" is already active.' + ).get_failure_response() + + now = timezone.now() + genre.is_active = True + genre.updated_by_id = user_id + genre.updated_at = now + genre.save(update_fields=['is_active', 'updated_by', 'updated_at']) + + return CustomResponse( + general_message=f'Genre "{genre.name}" reinstated successfully.', + response={'id': genre.id, 'is_active': True}, + ).get_success_response() diff --git a/api/muComics/comic/comic_views.py b/api/muComics/comic/comic_views.py new file mode 100644 index 000000000..a3c3f2d1c --- /dev/null +++ b/api/muComics/comic/comic_views.py @@ -0,0 +1,719 @@ +""" +Comic CRUD views. + +Endpoints: + GET /muComics/comics/ → list (paginated, search, filter, sort) + POST /muComics/comics/ → create + GET /muComics/comics// → detail + PATCH /muComics/comics// → partial update (creator or editor) + DELETE /muComics/comics// → soft delete (creator only) + POST /muComics/comics//publish/ → draft → published (creator only) + POST /muComics/comics//archive/ → published/draft → archived (creator only) +""" + +from django.utils import timezone +from django.db.models import Q +from django.db import IntegrityError + +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s +from rest_framework.views import APIView + +from db.comic import Comic, ComicContributorLink, ComicGenreLink, Genre +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.utils import CommonUtils +from utils.types import RoleType + +from .serializers import ( + ComicListItemSerializer, + ComicDetailSerializer, + ComicWriteSerializer, + ContributorWriteSerializer, + ContributorRoleUpdateSerializer, + ContributorListSerializer, + ComicGenreAssignSerializer, +) + + +# ───────────────────────────────────────────────────────────────────────────── +# HELPERS +# ───────────────────────────────────────────────────────────────────────────── + +def _get_active_comics(): + """Base queryset: exclude soft-deleted rows.""" + return Comic.objects.filter(deleted_at__isnull=True) + + +def _is_creator_or_admin(user_id, comic, request): + """ + True if the user is the comic's original creator + OR holds the platform Admin role in their JWT. + """ + if comic.created_by_id == user_id: + return True + return RoleType.ADMIN.value in JWTUtils.fetch_role(request) + + +def can_edit_comic(user_id, comic): + """ + True if the user is the comic's creator OR has been explicitly assigned + as an 'editor' contributor on that comic by the creator. + """ + if comic.created_by_id == user_id: + return True + return ComicContributorLink.objects.filter( + comic=comic, + user_id=user_id, + contributor_type=ComicContributorLink.ContributorType.EDITOR, + ).exists() + + +# ───────────────────────────────────────────────────────────────────────────── +# COMIC LIST + CREATE +# ───────────────────────────────────────────────────────────────────────────── + +class ComicListCreateView(APIView): + """ + GET /muComics/comics/ → paginated list with search, status filter, sort + POST /muComics/comics/ → create a new comic (any authenticated user) + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics'], + description="List all active (non-deleted) comics. Supports search by title, status filter, and sort by created_at or title.", + responses={200: ComicListItemSerializer(many=True)}, + ) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + queryset = _get_active_comics() + + # Optional status filter: ?status=draft|published|archived + if status_filter := request.query_params.get('status'): + if status_filter not in Comic.Status.values: + return CustomResponse( + general_message=f'Invalid status. Allowed: {", ".join(Comic.Status.values)}.' + ).get_failure_response() + queryset = queryset.filter(status=status_filter) + + # Optional multi-genre filter: ?genre=action,horror + # Accepts one or more genre slugs (comma-separated). + # Returns comics that have at least one of the given active genres. + if genre_param := request.query_params.get('genre'): + genre_slugs = [s.strip() for s in genre_param.split(',') if s.strip()] + + if genre_slugs: + queryset = queryset.filter( + genre_links__genre__slug__in=genre_slugs, + genre_links__genre__is_active=True, + ).distinct() + + paginated = CommonUtils.get_paginated_queryset( + queryset.select_related('created_by').prefetch_related('genre_links__genre'), + request, + search_fields=['title'], + sort_fields={ + 'created_at': 'created_at', + 'title': 'title', + }, + ) + + serializer = ComicListItemSerializer( + paginated['queryset'], many=True, + context={'user_id': user_id}, + ) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + @extend_schema( + tags=['muComics'], + description="Create a new comic. Any authenticated user may create a comic. Returns the full comic detail on success.", + request=ComicWriteSerializer, + responses={200: ComicDetailSerializer}, + ) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + + serializer = ComicWriteSerializer( + data=request.data, + context={'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + comic = serializer.save() + + return CustomResponse( + general_message=f'Comic "{comic.title}" created successfully.', + response=ComicDetailSerializer( + comic, context={'user_id': user_id} + ).data, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# COMIC DETAIL — GET / PATCH / DELETE +# ───────────────────────────────────────────────────────────────────────────── + +class ComicDetailView(APIView): + """ + GET /muComics/comics// → full detail + PATCH /muComics/comics// → partial update (creator or assigned editor) + DELETE /muComics/comics// → soft delete (creator only) + """ + authentication_classes = [CustomizePermission] + + def _get_comic_or_error(self, comic_id): + """Fetch an active comic or return an error string.""" + comic = _get_active_comics().filter(id=comic_id).first() + if not comic: + return None, 'Comic not found.' + return comic, None + + @extend_schema( + tags=['muComics'], + description="Retrieve full detail for a single active comic, including contributors and genres.", + responses={200: ComicDetailSerializer}, + ) + def get(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + comic, error = self._get_comic_or_error(comic_id) + if error: + return CustomResponse(general_message=error).get_failure_response() + + return CustomResponse( + general_message=f'Comic "{comic.title}" retrieved successfully.', + response=ComicDetailSerializer( + comic, context={'user_id': user_id} + ).data, + ).get_success_response() + + @extend_schema( + tags=['muComics'], + description="Partially update a comic's title, description, or cover image. Only the creator or an assigned editor contributor may edit. Archived comics cannot be edited.", + request=ComicWriteSerializer, + responses={200: ComicDetailSerializer}, + ) + def patch(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + comic, error = self._get_comic_or_error(comic_id) + if error: + return CustomResponse(general_message=error).get_failure_response() + + # Permission: creator or assigned editor + if not can_edit_comic(user_id, comic): + return CustomResponse( + general_message='You do not have permission to edit this comic.' + ).get_unauthorized_response() + + # Archived comics cannot be edited + if comic.status == Comic.Status.ARCHIVED: + return CustomResponse( + general_message='Archived comics cannot be edited.' + ).get_failure_response() + + serializer = ComicWriteSerializer( + comic, data=request.data, + partial=True, + context={'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + comic = serializer.save() + + return CustomResponse( + general_message=f'Comic "{comic.title}" updated successfully.', + response=ComicDetailSerializer( + comic, context={'user_id': user_id} + ).data, + ).get_success_response() + + @extend_schema( + tags=['muComics'], + description="Soft-delete a comic (sets deleted_at). Only the original creator may delete. The comic is hidden from all list/detail responses after deletion.", + responses={200: inline_serializer( + name='ComicDeleteResponse', + fields={'id': s.CharField()}, + )}, + ) + def delete(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + comic, error = self._get_comic_or_error(comic_id) + if error: + return CustomResponse(general_message=error).get_failure_response() + + # Only the original creator can delete + if comic.created_by_id != user_id: + return CustomResponse( + general_message='Only the comic creator can delete this comic.' + ).get_unauthorized_response() + + now = timezone.now() + comic.deleted_at = now + comic.deleted_by_id = user_id + comic.updated_by_id = user_id + comic.updated_at = now + comic.save() + + return CustomResponse( + general_message=f'Comic "{comic.title}" deleted successfully.', + response={'id': comic.id}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# PUBLISH +# ───────────────────────────────────────────────────────────────────────────── + +class ComicPublishView(APIView): + """ + POST /muComics/comics//publish/ + Transitions a draft comic to published (creator only). + + Workflow: + draft → published ✅ + archived → ❌ (cannot re-publish archived) + published → ❌ (already published) + """ + authentication_classes = [CustomizePermission] + + REQUIRED_FIELDS = ['title', 'description'] + + @extend_schema( + tags=['muComics'], + description="Publish a draft comic (draft → published). Only the creator may publish. Requires title and description to be present. Archived comics cannot be re-published.", + responses={200: inline_serializer( + name='ComicPublishResponse', + fields={ + 'id': s.CharField(), + 'status': s.CharField(), + }, + )}, + ) + def post(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + comic = _get_active_comics().filter(id=comic_id).first() + + if not comic: + return CustomResponse(general_message='Comic not found.').get_failure_response() + + # Only creator can publish + if comic.created_by_id != user_id: + return CustomResponse( + general_message='Only the comic creator can publish this comic.' + ).get_unauthorized_response() + + if comic.status == Comic.Status.PUBLISHED: + return CustomResponse( + general_message='Comic is already published.' + ).get_failure_response() + + if comic.status == Comic.Status.ARCHIVED: + return CustomResponse( + general_message='Archived comics cannot be published. Unarchive it first.' + ).get_failure_response() + + # Validate required fields are filled before publishing + missing = [f for f in self.REQUIRED_FIELDS if not getattr(comic, f, None)] + if missing: + return CustomResponse( + general_message=f'Cannot publish: missing required fields: {", ".join(missing)}.' + ).get_failure_response() + + now = timezone.now() + comic.status = Comic.Status.PUBLISHED + comic.published_at = now + comic.updated_by_id = user_id + comic.updated_at = now + comic.save(update_fields=['status', 'published_at', 'updated_by', 'updated_at']) + + return CustomResponse( + general_message=f'Comic "{comic.title}" published successfully.', + response={'id': comic.id, 'status': Comic.Status.PUBLISHED}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# ARCHIVE +# ───────────────────────────────────────────────────────────────────────────── + +class ComicArchiveView(APIView): + """ + POST /muComics/comics//archive/ + Transitions a draft or published comic to archived (creator only). + + Workflow: + draft → archived ✅ + published → archived ✅ + archived → ❌ (already archived) + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics'], + description="Archive a draft or published comic (draft/published → archived). Only the creator may archive. Already-archived comics are rejected.", + responses={200: inline_serializer( + name='ComicArchiveResponse', + fields={ + 'id': s.CharField(), + 'status': s.CharField(), + }, + )}, + ) + def post(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + comic = _get_active_comics().filter(id=comic_id).first() + + if not comic: + return CustomResponse(general_message='Comic not found.').get_failure_response() + + # Only creator can archive + if comic.created_by_id != user_id: + return CustomResponse( + general_message='Only the comic creator can archive this comic.' + ).get_unauthorized_response() + + if comic.status == Comic.Status.ARCHIVED: + return CustomResponse( + general_message='Comic is already archived.' + ).get_failure_response() + + now = timezone.now() + comic.status = Comic.Status.ARCHIVED + comic.updated_by_id = user_id + comic.updated_at = now + comic.save(update_fields=['status', 'updated_by', 'updated_at']) + + return CustomResponse( + general_message=f'Comic "{comic.title}" archived successfully.', + response={'id': comic.id, 'status': Comic.Status.ARCHIVED}, + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# CONTRIBUTORS +# ───────────────────────────────────────────────────────────────────────────── + +class ComicContributorListView(APIView): + """ + GET /muComics/comics//contributors/ + → list all contributors for the comic (any authenticated user) + + POST /muComics/comics//contributors/ + → add a contributor (comic creator or Admin only) + Request body: { "muid": "...", "role": "ARTIST" } + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics'], + description="List all contributors for a comic.", + responses={200: ContributorListSerializer(many=True)}, + ) + # def get(self, request, comic_id): + # comic = _get_active_comics().filter(id=comic_id).first() + # if not comic: + # return CustomResponse(general_message='Comic not found.').get_failure_response() + + # role_filter = request.query_params.get('role') + # links = ComicContributorLink.objects.filter(comic=comic) + + # if role_filter: + # links = links.filter(contributor_type=role_filter) + + # links = links.select_related('user', 'comic') + + # if not links: + # if role_filter: + # return CustomResponse(general_message=f'No {role_filter} contributor found for this comic.', response=[]).get_success_response() + # return CustomResponse(general_message='There are no contributors for this comic.', response=[]).get_success_response() + + # serializer = ContributorListSerializer(links, many=True) + # return CustomResponse(response=serializer.data).get_success_response() + def get(self, request, comic_id): + comic = _get_active_comics().filter(id=comic_id).first() + if not comic: + return CustomResponse(general_message='Comic not found.').get_failure_response() + + links = ComicContributorLink.objects.filter(comic=comic).select_related('user', 'comic') + + role_filter = request.query_params.get('role') + if role_filter: + links = links.filter(contributor_type=role_filter) + + paginated = CommonUtils.get_paginated_queryset( + links, + request, + search_fields=['user__full_name', 'contributor_type'], + sort_fields={'role': 'contributor_type'}, + ) + + serializer = ContributorListSerializer(paginated['queryset'], many=True) + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + @extend_schema( + tags=['muComics'], + description="Add a contributor to a comic by muid and role. Only the comic creator or an Admin may call this. The CREATOR role cannot be assigned via this endpoint.", + request=ContributorWriteSerializer, + responses={200: inline_serializer( + name='ContributorAddResponse', + fields={'message': s.CharField()}, + )}, + ) + def post(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + comic = _get_active_comics().filter(id=comic_id).first() + if not comic: + return CustomResponse(general_message='Comic not found.').get_failure_response() + + if not _is_creator_or_admin(user_id, comic, request): + return CustomResponse( + general_message='Only the comic creator or an admin can add contributors.' + ).get_unauthorized_response() + + serializer = ContributorWriteSerializer( + data=request.data, + context={'comic': comic, 'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + try: + serializer.save() + except IntegrityError: + return CustomResponse( + general_message='Contributor with this role already exists.' + ).get_failure_response() + + # muid comes from validated_data['muid'] which is a User object after validate_muid + muid = serializer.validated_data['muid'].muid + role = serializer.validated_data['role'] + return CustomResponse( + general_message=f'{role} {muid} added successfully.' + ).get_success_response() + + +class ComicContributorDetailView(APIView): + """ + PATCH /muComics/comics//contributors// + → update a contributor's role (comic creator or Admin only) + + DELETE /muComics/comics//contributors// + → remove a contributor (comic creator or Admin only) + """ + authentication_classes = [CustomizePermission] + + def _get_link_or_error(self, comic, contributor_id): + """Fetch a ComicContributorLink by PK scoped to the given comic.""" + link = ( + ComicContributorLink.objects + .filter(id=contributor_id, comic=comic) + .select_related('user') + .first() + ) + if not link: + return None, 'Contributor not found.' + return link, None + + @extend_schema( + tags=['muComics'], + description="Update a contributor's role. Only the comic creator or an Admin may call this. The CREATOR role cannot be assigned.", + request=ContributorRoleUpdateSerializer, + responses={200: inline_serializer( + name='ContributorUpdateResponse', + fields={'message': s.CharField()}, + )}, + ) + def patch(self, request, comic_id, contributor_id): + user_id = JWTUtils.fetch_user_id(request) + comic = _get_active_comics().filter(id=comic_id).first() + if not comic: + return CustomResponse(general_message='Comic not found.').get_failure_response() + + if not _is_creator_or_admin(user_id, comic, request): + return CustomResponse( + general_message='Only the comic creator or an admin can update contributors.' + ).get_unauthorized_response() + + link, error = self._get_link_or_error(comic, contributor_id) + if error: + return CustomResponse(general_message=error).get_failure_response() + + serializer = ContributorRoleUpdateSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + link.contributor_type = serializer.validated_data['role'] + try: + link.save(update_fields=['contributor_type']) + except IntegrityError: + return CustomResponse( + general_message='Contributor with this role already exists.' + ).get_failure_response() + + return CustomResponse( + general_message=f'{link.contributor_type} {link.user.muid} updated.' + ).get_success_response() + + @extend_schema( + tags=['muComics'], + description="Remove a contributor from a comic. Only the comic creator or an Admin may call this.", + responses={200: inline_serializer( + name='ContributorRemoveResponse', + fields={'message': s.CharField()}, + )}, + ) + def delete(self, request, comic_id, contributor_id): + user_id = JWTUtils.fetch_user_id(request) + comic = _get_active_comics().filter(id=comic_id).first() + if not comic: + return CustomResponse(general_message='Comic not found.').get_failure_response() + + if not _is_creator_or_admin(user_id, comic, request): + return CustomResponse( + general_message='Only the comic creator or an admin can remove contributors.' + ).get_unauthorized_response() + + link, error = self._get_link_or_error(comic, contributor_id) + if error: + return CustomResponse(general_message=error).get_failure_response() + + muid = link.user.muid + role=link.contributor_type + link.delete() + + return CustomResponse( + general_message=f'{role} {muid} removed.' + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# COMIC GENRE LINKS (assign / remove) +# ───────────────────────────────────────────────────────────────────────────── + +class ComicGenreListView(APIView): + """ + POST /muComics/comics//genres/ + Assign a genre to a comic. + Required role: Creator, Editor or Admin + Request body: { "genre_id": "" } + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics'], + description="Assign a genre to a comic. Only the creator, an assigned editor, or an admin might perform this action.", + request=ComicGenreAssignSerializer, + responses={200: inline_serializer( + name='ComicGenreAssignResponse', + fields={ + 'link_id': s.CharField(), + 'genre': inline_serializer( + name='GenreMinimalObj', + fields={ + 'id': s.CharField(), + 'name': s.CharField(), + 'slug': s.CharField(), + } + ) + } + )}, + ) + def post(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + comic = _get_active_comics().filter(id=comic_id).first() + if not comic: + return CustomResponse(general_message='Comic not found.').get_failure_response() + + # Permission role check: Creator, Editor or Admin + if not (can_edit_comic(user_id, comic) or RoleType.ADMIN.value in JWTUtils.fetch_role(request)): + return CustomResponse( + general_message='Only the comic creator, assigned editor, or an Admin can manage genres for this comic.' + ).get_unauthorized_response() + + serializer = ComicGenreAssignSerializer( + data=request.data, + context={'comic': comic, 'user_id': user_id}, + ) + if not serializer.is_valid(): + return CustomResponse( + general_message=serializer.errors + ).get_failure_response() + + try: + link = serializer.save() + except IntegrityError: + return CustomResponse( + general_message='This genre is already assigned to this comic.' + ).get_failure_response() + + return CustomResponse( + general_message=f'Genre "{link.genre.name}" assigned to comic "{comic.title}" successfully.', + response={ + 'link_id': link.id, + 'genre': { + 'id': link.genre.id, + 'name': link.genre.name, + 'slug': link.genre.slug, + } + }, + ).get_success_response() + + +class ComicGenreDetailView(APIView): + """ + DELETE /muComics/comics//genres// + Remove a genre link from a comic. + Required role: Creator, Editor or Admin + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['muComics'], + description="Remove a genre from a comic. Only the creator, an assigned editor, or an admin might perform this action.", + responses={200: inline_serializer( + name='ComicGenreRemoveResponse', + fields={'link_id': s.CharField()} + )}, + ) + def delete(self, request, comic_id, link_id): + user_id = JWTUtils.fetch_user_id(request) + comic = _get_active_comics().filter(id=comic_id).first() + if not comic: + return CustomResponse(general_message=f'Comic {comic_id} not found.').get_failure_response() + + # Permission role check: Creator, Editor or Admin + if not (can_edit_comic(user_id, comic) or RoleType.ADMIN.value in JWTUtils.fetch_role(request)): + return CustomResponse( + general_message='Only the comic creator, assigned editor, or an Admin can manage genres for this comic.' + ).get_unauthorized_response() + + link = ComicGenreLink.objects.filter(id=link_id, comic=comic).select_related('genre').first() + if not link: + return CustomResponse( + general_message='Genre link not found for this comic.' + ).get_failure_response() + + genre_name = link.genre.name + link.delete() + + return CustomResponse( + general_message=f'Genre "{genre_name}" removed from comic "{comic.title}" successfully.', + response={'link_id': link_id}, + ).get_success_response() + diff --git a/api/muComics/comic/serializers.py b/api/muComics/comic/serializers.py new file mode 100644 index 000000000..5115affb0 --- /dev/null +++ b/api/muComics/comic/serializers.py @@ -0,0 +1,398 @@ +""" +Serializers for the Comic CRUD module. + +Shapes: + ComicListItemSerializer – lean, used in paginated list responses + ComicDetailSerializer – full, used in detail / create / update responses + ComicWriteSerializer – input, used for POST and PATCH +""" + +import uuid + +from django.utils.text import slugify +from django.utils import timezone +from rest_framework import serializers + +from db.comic import Comic, Genre, ComicContributorLink, ComicGenreLink +from db.user import User + + +# ───────────────────────────────────────────────────────────────────────────── +# MINIMAL NESTED SHAPES +# ───────────────────────────────────────────────────────────────────────────── + +class MinimalUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'full_name', 'muid'] + + +class MinimalGenreSerializer(serializers.ModelSerializer): + class Meta: + model = Genre + fields = ['id', 'name', 'slug'] + + +class ContributorSerializer(serializers.ModelSerializer): + user = MinimalUserSerializer(read_only=True) + + class Meta: + model = ComicContributorLink + fields = ['id', 'user', 'contributor_type', 'created_at'] + + +# ───────────────────────────────────────────────────────────────────────────── +# COMIC LIST (lean — for paginated feeds) +# ───────────────────────────────────────────────────────────────────────────── + +class ComicListItemSerializer(serializers.ModelSerializer): + created_by = MinimalUserSerializer(read_only=True) + genres = serializers.SerializerMethodField() + + class Meta: + model = Comic + fields = [ + 'id', 'title', 'slug', 'cover_image_key', + 'status', 'like_count', 'comment_count', 'bookmark_count', + 'published_at', 'created_by', 'created_at', + 'genres', + ] + + def get_genres(self, obj): + links = obj.genre_links.all() + active_genres = [link.genre for link in links if link.genre.is_active] + return MinimalGenreSerializer(active_genres, many=True).data + + + +# ───────────────────────────────────────────────────────────────────────────── +# COMIC DETAIL (full — for detail / create / update responses) +# ───────────────────────────────────────────────────────────────────────────── + +class ComicDetailSerializer(serializers.ModelSerializer): + created_by = MinimalUserSerializer(read_only=True) + updated_by = MinimalUserSerializer(read_only=True) + genres = serializers.SerializerMethodField() + contributors = serializers.SerializerMethodField() + + class Meta: + model = Comic + fields = [ + 'id', 'title', 'slug', 'description', 'cover_image_key', + 'status', 'like_count', 'comment_count', 'bookmark_count', + 'published_at', + 'genres', 'contributors', + 'created_by', 'created_at', + 'updated_by', 'updated_at', + ] + + def get_genres(self, obj): + # Option A: only active genres shown; inactive genre hides from comic detail + links = obj.genre_links.select_related('genre').filter(genre__is_active=True) + return MinimalGenreSerializer( + [link.genre for link in links], many=True + ).data + + def get_contributors(self, obj): + links = obj.contributor_links.select_related('user').all() + return ContributorSerializer(links, many=True).data + + +# ───────────────────────────────────────────────────────────────────────────── +# COMIC WRITE (create / update input) +# ───────────────────────────────────────────────────────────────────────────── + +class ComicWriteSerializer(serializers.ModelSerializer): + """ + Input serializer for POST /comics/ and PATCH /comics//. + The caller never sends: slug, status, *_count, published_at, or audit fields. + """ + + class Meta: + model = Comic + fields = ['title', 'description', 'cover_image_key'] + extra_kwargs = { + 'title': { + 'required': True, + 'max_length': 150, # matches Comic.title max_length in db/comic.py + }, + 'description': { + 'required': False, + 'allow_null': True, + 'allow_blank': True, + }, + 'cover_image_key': { + 'required': False, + 'allow_null': True, + 'allow_blank': True, + 'max_length': 255, # matches Comic.cover_image_key max_length in db/comic.py + }, + } + + def validate_title(self, value): + if not value or not value.strip(): + raise serializers.ValidationError('Title must not be blank.') + return value.strip() + + def _generate_unique_slug(self, title): + """ + Generates a URL-safe slug from title. + Appends an incrementing counter if the base slug already exists + (same pattern as EventWriteSerializer._generate_unique_slug). + Slug is truncated to 70 chars before suffix to stay within max_length=75. + """ + base = slugify(title)[:70] + slug = base + counter = 1 + # On update: exclude the current comic so its own slug is not treated as a collision. + # On create: self.instance is None, so no exclusion is applied. + exclude_id = self.instance.id if self.instance else None + while True: + qs = Comic.objects.filter(slug=slug) + if exclude_id: + qs = qs.exclude(id=exclude_id) + if not qs.exists(): + break + slug = f'{base}-{counter}' + counter += 1 + return slug + + def create(self, validated_data): + user_id = self.context['user_id'] + now = timezone.now() + + validated_data['id'] = str(uuid.uuid4()) + validated_data['slug'] = self._generate_unique_slug(validated_data['title']) + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + validated_data['created_at'] = now + validated_data['updated_at'] = now + return Comic.objects.create(**validated_data) + + def update(self, instance, validated_data): + user_id = self.context['user_id'] + now = timezone.now() + + # Re-generate slug only when title changes + if 'title' in validated_data and validated_data['title'] != instance.title: + validated_data['slug'] = self._generate_unique_slug(validated_data['title']) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.updated_by_id = user_id + instance.updated_at = now + + # Only write columns this PATCH actually touched. + # 'status' and 'published_at' are intentionally absent — this prevents + # a concurrent publish/archive from being silently reverted. + changed_fields = list(validated_data.keys()) + if 'title' in changed_fields: + changed_fields.append('slug') # slug is derived from title + changed_fields += ['updated_by', 'updated_at'] + + instance.save(update_fields=changed_fields) + return instance + + +# ───────────────────────────────────────────────────────────────────────────── +# CONTRIBUTOR MANAGEMENT (add / list / update-role / remove) +# ───────────────────────────────────────────────────────────────────────────── + +# Valid roles that can be assigned via the API. +# CREATOR is intentionally excluded — it is tracked via comic.created_by. +_ASSIGNABLE_CONTRIBUTOR_CHOICES = [ + (ct.value, ct.label) + for ct in ComicContributorLink.ContributorType + if ct != ComicContributorLink.ContributorType.CREATOR +] + + +class ContributorWriteSerializer(serializers.Serializer): + """ + Input for POST /comics/{comicId}/contributors/. + Resolves `muid` → User object during validation so create() receives a + ready-to-use User instance. + """ + muid = serializers.CharField() + role = serializers.ChoiceField(choices=_ASSIGNABLE_CONTRIBUTOR_CHOICES) + + def validate_muid(self, value): + try: + return User.objects.get(muid=value, is_active=True) + except User.DoesNotExist: + raise serializers.ValidationError(f'No Active user with muid "{value}" not found.') + + def create(self, validated_data): + comic = self.context['comic'] + user = validated_data['muid'] # User object — resolved in validate_muid + role = validated_data['role'] + user_id = self.context['user_id'] + now = timezone.now() + + return ComicContributorLink.objects.create( + id = str(uuid.uuid4()), + comic = comic, + user = user, + contributor_type = role, + created_by_id = user_id, + created_at = now, + ) + + +class ContributorRoleUpdateSerializer(serializers.Serializer): + """Input for PATCH /comics/{comicId}/contributors/{contributorId}/.""" + role = serializers.ChoiceField(choices=_ASSIGNABLE_CONTRIBUTOR_CHOICES) + + +class ContributorListSerializer(serializers.ModelSerializer): + """ + Output shape for GET /comics/{comicId}/contributors/. + Flattens the ComicContributorLink → User / Comic relations. + """ + contributor_id = serializers.CharField(source='id') + user_id = serializers.CharField(source='user.id') + role = serializers.CharField(source='contributor_type') + name = serializers.CharField(source='user.full_name') + comic_name = serializers.CharField(source='comic.title') + + class Meta: + model = ComicContributorLink + fields = ['contributor_id', 'user_id', 'role', 'name', 'comic_name'] + + +# ───────────────────────────────────────────────────────────────────────────── +# GENRE MANAGEMENT (admin read / write) +# ───────────────────────────────────────────────────────────────────────────── + +class GenreReadSerializer(serializers.ModelSerializer): + """Output shape for genre list / detail responses.""" + + class Meta: + model = Genre + fields = ['id', 'name', 'slug', 'is_active', 'created_at', 'updated_at'] + + +class GenreWriteSerializer(serializers.ModelSerializer): + """ + Input serializer for POST /comics/genres/ and PATCH /comics/genres//. + Caller only sends `name`; slug and audit fields are set internally. + """ + + class Meta: + model = Genre + fields = ['name'] + extra_kwargs = { + 'name': { + 'required': True, + 'max_length': 75, + 'allow_blank': False, + }, + } + + def validate_name(self, value): + if not value or not value.strip(): + raise serializers.ValidationError('Name must not be blank.') + return value.strip() + + def _generate_unique_slug(self, name): + """ + URL-safe slug from name. Appends an incrementing counter on collision. + Truncated to 70 chars before suffix to stay within max_length=75. + On update, excludes the current genre's own slug from collision check. + """ + base = slugify(name)[:70] + slug = base + counter = 1 + exclude_id = self.instance.id if self.instance else None + + while True: + qs = Genre.objects.filter(slug=slug) + if exclude_id: + qs = qs.exclude(id=exclude_id) + if not qs.exists(): + break + slug = f'{base}-{counter}' + counter += 1 + return slug + + def create(self, validated_data): + user_id = self.context['user_id'] + now = timezone.now() + + validated_data['id'] = str(uuid.uuid4()) + validated_data['slug'] = self._generate_unique_slug(validated_data['name']) + validated_data['is_active'] = True + validated_data['created_by_id'] = user_id + validated_data['updated_by_id'] = user_id + validated_data['created_at'] = now + validated_data['updated_at'] = now + + return Genre.objects.create(**validated_data) + + def update(self, instance, validated_data): + user_id = self.context['user_id'] + now = timezone.now() + + if 'name' in validated_data and validated_data['name'] != instance.name: + validated_data['slug'] = self._generate_unique_slug(validated_data['name']) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.updated_by_id = user_id + instance.updated_at = now + + changed_fields = list(validated_data.keys()) + if 'name' in changed_fields: + changed_fields.append('slug') + changed_fields += ['updated_by', 'updated_at'] + + instance.save(update_fields=changed_fields) + return instance + + +# ───────────────────────────────────────────────────────────────────────────── +# COMIC GENRE LINK MANAGEMENT +# ───────────────────────────────────────────────────────────────────────────── + +class ComicGenreAssignSerializer(serializers.Serializer): + """ + Input serializer for POST /comics/{comicId}/genres/. + Validates that: + - The genre exists. + - The genre is active (is_active=True). + - The genre is not already assigned to this comic. + """ + genre_id = serializers.CharField() + + def validate_genre_id(self, value): + try: + genre = Genre.objects.get(id=value) + except Genre.DoesNotExist: + raise serializers.ValidationError(f"Genre with id '{value}' not found.") + + if not genre.is_active: + raise serializers.ValidationError("Cannot assign an inactive genre.") + + comic = self.context['comic'] + if ComicGenreLink.objects.filter(comic=comic, genre=genre).exists(): + raise serializers.ValidationError("This genre is already assigned to the comic.") + + return genre + + def create(self, validated_data): + comic = self.context['comic'] + genre = validated_data['genre_id'] # This is a Genre instance populated in validate_genre_id + user_id = self.context['user_id'] + now = timezone.now() + + return ComicGenreLink.objects.create( + id=str(uuid.uuid4()), + comic=comic, + genre=genre, + created_by_id=user_id, + created_at=now, + ) + + diff --git a/api/muComics/comic/urls.py b/api/muComics/comic/urls.py new file mode 100644 index 000000000..4afbfdcf6 --- /dev/null +++ b/api/muComics/comic/urls.py @@ -0,0 +1,47 @@ +from django.urls import path + +from . import comic_views, admin_views + +urlpatterns = [ + # ── Genre management (Admin) ────────────────────────────────────────────── + # IMPORTANT: Must come BEFORE / patterns to avoid shadowing. + path('genres/', + admin_views.GenreListCreateView.as_view(), + name='genre-list-create'), + + path('genres//', + admin_views.GenreDetailView.as_view(), + name='genre-detail'), + + path('genres//reinstate/', + admin_views.GenreReinstateView.as_view(), + name='genre-reinstate'), + + # ── Comic CRUD ──────────────────────────────────────────────────────────── + # List + Create + path('', comic_views.ComicListCreateView.as_view(), name='comic-list-create'), + + # Detail + Update + Delete + path('/', comic_views.ComicDetailView.as_view(), name='comic-detail'), + + # Status workflow + path('/publish/', comic_views.ComicPublishView.as_view(), name='comic-publish'), + path('/archive/', comic_views.ComicArchiveView.as_view(), name='comic-archive'), + + # Contributors + path('/contributors/', + comic_views.ComicContributorListView.as_view(), + name='comic-contributor-list'), + path('/contributors//', + comic_views.ComicContributorDetailView.as_view(), + name='comic-contributor-detail'), + + # Comic Genre Links + path('/genres/', + comic_views.ComicGenreListView.as_view(), + name='comic-genre-assign'), + path('/genres//', + comic_views.ComicGenreDetailView.as_view(), + name='comic-genre-remove'), +] + diff --git a/api/muComics/comment/__init__.py b/api/muComics/comment/__init__.py new file mode 100644 index 000000000..3a036c1e3 --- /dev/null +++ b/api/muComics/comment/__init__.py @@ -0,0 +1 @@ +# MuComics Comment module diff --git a/api/muComics/comment/admin_views.py b/api/muComics/comment/admin_views.py new file mode 100644 index 000000000..0b2f9f555 --- /dev/null +++ b/api/muComics/comment/admin_views.py @@ -0,0 +1,121 @@ +""" +MuComics Admin/Moderation views (Endpoints 7-8). +Requires Comic Admin role. +""" +from django.utils import timezone +from django.db import transaction +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from db.comic_comment import ComicComment +from utils.permission import CustomizePermission, JWTUtils, role_required +from utils.response import CustomResponse +from utils.utils import CommonUtils + +from .comment_serializers import AdminCommentListSerializer +from .comment_views import _decrement_comment_count + + +class AdminCommentListAPI(APIView): + """ + GET /admin/comments/ — list all comments for moderation (flat list) + """ + permission_classes = [CustomizePermission] + + @extend_schema(tags=['muComics']) + @role_required(["Comic Admin"]) + def get(self, request): + queryset = ComicComment.objects.select_related( + 'user', 'comic', 'deleted_by' + ).prefetch_related('replies').order_by('-created_at') + + # Filter by comic_id + comic_id = request.query_params.get('comic_id') + if comic_id: + queryset = queryset.filter(comic_id=comic_id) + + # Filter by chapter_id + chapter_id = request.query_params.get('chapter_id') + if chapter_id: + queryset = queryset.filter(chapter_id=chapter_id) + + # Filter by user_id + user_id_filter = request.query_params.get('user_id') + if user_id_filter: + queryset = queryset.filter(user_id=user_id_filter) + + # Deleted filters + include_deleted = request.query_params.get('include_deleted', 'false').lower() == 'true' + deleted_only = request.query_params.get('deleted_only', 'false').lower() == 'true' + + if deleted_only: + queryset = queryset.filter(deleted_at__isnull=False) + elif not include_deleted: + queryset = queryset.filter(deleted_at__isnull=True) + + paginated = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=['message'], + sort_fields={'created_at': 'created_at'}, + ) + + # Bulk fetch chapter titles for the paginated page + chapter_ids = [c.chapter_id for c in paginated['queryset'] if c.chapter_id] + chapter_titles = {} + if chapter_ids: + unique_chapter_ids = list(set(chapter_ids)) + from django.db import connection + with connection.cursor() as cursor: + format_strings = ','.join(['%s'] * len(unique_chapter_ids)) + cursor.execute(f"SELECT id, title FROM chapter WHERE id IN ({format_strings})", unique_chapter_ids) + for row in cursor.fetchall(): + chapter_titles[row[0]] = row[1] + + serializer = AdminCommentListSerializer( + paginated['queryset'], many=True, + context={'chapter_titles': chapter_titles} + ) + + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class AdminCommentDeleteAPI(APIView): + """ + DELETE /admin/comments// — soft-delete any comment (moderator) + """ + permission_classes = [CustomizePermission] + + @extend_schema(tags=['muComics']) + @role_required(["Comic Admin"]) + def delete(self, request, comment_id): + user_id = JWTUtils.fetch_user_id(request) + + try: + comment = ComicComment.objects.get(id=comment_id) + except ComicComment.DoesNotExist: + return CustomResponse( + general_message="Comment not found" + ).get_failure_response(status_code=404, http_status_code=404) + + if comment.is_deleted: + return CustomResponse( + general_message="Comment has already been deleted" + ).get_failure_response() + + now = timezone.now() + with transaction.atomic(): + comment.deleted_at = now + comment.deleted_by_id = user_id + comment.updated_at = now + comment.updated_by_id = user_id + comment.save() + + _decrement_comment_count(comment.comic_id) + + return CustomResponse( + general_message="Comment removed by moderator" + ).get_success_response() diff --git a/api/muComics/comment/comment_serializers.py b/api/muComics/comment/comment_serializers.py new file mode 100644 index 000000000..e49590d3e --- /dev/null +++ b/api/muComics/comment/comment_serializers.py @@ -0,0 +1,164 @@ +from rest_framework import serializers + +from db.user import User + + +class CommentUserSerializer(serializers.ModelSerializer): + """Lightweight user info embedded in comment responses.""" + profile_pic = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ['id', 'full_name', 'muid', 'profile_pic'] + + def get_profile_pic(self, obj): + return obj.profile_pic + + +class CommentReplySerializer(serializers.Serializer): + """Single reply (no further nesting).""" + id = serializers.CharField() + parent_id = serializers.CharField() + user = serializers.SerializerMethodField() + message = serializers.SerializerMethodField() + is_edited = serializers.BooleanField() + is_deleted = serializers.SerializerMethodField() + is_owner = serializers.SerializerMethodField() + created_at = serializers.DateTimeField() + updated_at = serializers.DateTimeField() + + def get_user(self, obj): + return CommentUserSerializer(obj.user).data + + def get_message(self, obj): + if obj.is_deleted: + return "[deleted]" + return obj.message + + def get_is_deleted(self, obj): + return obj.is_deleted + + def get_is_owner(self, obj): + request_user_id = self.context.get('user_id') + if not request_user_id: + return False + return obj.user_id == request_user_id + + +class CommentListSerializer(serializers.Serializer): + """Top-level comment with nested replies.""" + id = serializers.CharField() + comic_id = serializers.CharField() + chapter_id = serializers.CharField(allow_null=True) + parent_id = serializers.CharField(allow_null=True) + user = serializers.SerializerMethodField() + message = serializers.SerializerMethodField() + is_edited = serializers.BooleanField() + is_deleted = serializers.SerializerMethodField() + is_owner = serializers.SerializerMethodField() + reply_count = serializers.SerializerMethodField() + replies = serializers.SerializerMethodField() + created_at = serializers.DateTimeField() + updated_at = serializers.DateTimeField() + + def get_user(self, obj): + return CommentUserSerializer(obj.user).data + + def get_message(self, obj): + if obj.is_deleted: + return "[deleted]" + return obj.message + + def get_is_deleted(self, obj): + return obj.is_deleted + + def get_is_owner(self, obj): + request_user_id = self.context.get('user_id') + if not request_user_id: + return False + return obj.user_id == request_user_id + + def get_reply_count(self, obj): + if hasattr(obj, 'active_replies'): + return len(obj.active_replies) + return obj.replies.filter(deleted_at__isnull=True).count() + + def get_replies(self, obj): + if hasattr(obj, 'active_replies'): + active_replies = obj.active_replies + else: + active_replies = obj.replies.filter( + deleted_at__isnull=True + ).select_related('user').order_by('created_at') + + return CommentReplySerializer( + active_replies, many=True, context=self.context + ).data + + +class CommentCreateSerializer(serializers.Serializer): + """Validates POST body for comment creation.""" + message = serializers.CharField(min_length=1, max_length=2000) + parent_id = serializers.CharField(required=False, allow_null=True, default=None) + + def validate_message(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("Message cannot be empty or whitespace only.") + return value + + +class CommentUpdateSerializer(serializers.Serializer): + """Validates PATCH body for comment editing.""" + message = serializers.CharField(min_length=1, max_length=2000) + + def validate_message(self, value): + value = value.strip() + if not value: + raise serializers.ValidationError("Message cannot be empty or whitespace only.") + return value + + +class AdminCommentListSerializer(serializers.Serializer): + """Admin view — flat list with extra metadata.""" + id = serializers.CharField() + comic_id = serializers.CharField() + comic_title = serializers.SerializerMethodField() + chapter_id = serializers.CharField(allow_null=True) + chapter_title = serializers.SerializerMethodField() + parent_id = serializers.CharField(allow_null=True) + user = serializers.SerializerMethodField() + message = serializers.CharField() + is_edited = serializers.BooleanField() + is_deleted = serializers.SerializerMethodField() + deleted_at = serializers.DateTimeField(allow_null=True) + deleted_by_user = serializers.SerializerMethodField() + reply_count = serializers.SerializerMethodField() + created_at = serializers.DateTimeField() + updated_at = serializers.DateTimeField() + + def get_comic_title(self, obj): + return obj.comic.title if obj.comic else None + + def get_chapter_title(self, obj): + if not obj.chapter_id: + return None + return self.context.get('chapter_titles', {}).get(obj.chapter_id) + + def get_user(self, obj): + return CommentUserSerializer(obj.user).data + + def get_is_deleted(self, obj): + return obj.is_deleted + + def get_deleted_by_user(self, obj): + if not obj.deleted_by: + return None + return { + "id": obj.deleted_by.id, + "full_name": obj.deleted_by.full_name, + "muid": obj.deleted_by.muid, + } + + def get_reply_count(self, obj): + return len([r for r in obj.replies.all() if not r.is_deleted]) diff --git a/api/muComics/comment/comment_views.py b/api/muComics/comment/comment_views.py new file mode 100644 index 000000000..03ce0aa92 --- /dev/null +++ b/api/muComics/comment/comment_views.py @@ -0,0 +1,443 @@ +""" +MuComics Comment views — public comment CRUD (Endpoints 1-6). + +Endpoints: + GET /muComics/comments/comic// → list comic comments + GET /muComics/comments/chapter// → list chapter comments + POST /muComics/comments/comic// → create comic comment + POST /muComics/comments/chapter// → create chapter comment + PATCH /muComics/comments// → edit own comment + DELETE /muComics/comments// → soft-delete own comment +""" +import uuid + +from django.db import connection, transaction +from django.db.models import Q, Exists, OuterRef, Prefetch +from django.utils import timezone +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from db.comic import Comic +from db.comic_comment import ComicComment +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.utils import CommonUtils + +from .comment_serializers import ( + CommentListSerializer, + CommentCreateSerializer, + CommentUpdateSerializer, + CommentUserSerializer, +) + + +def _get_user_id_or_none(request): + """Safely extract user_id from JWT; returns None if unauthenticated.""" + try: + return JWTUtils.fetch_user_id(request) + except Exception: + return None + + +def _chapter_exists(chapter_id): + """Check if a chapter exists and return its comic_id.""" + with connection.cursor() as cursor: + cursor.execute( + "SELECT comic_id FROM chapter WHERE id = %s LIMIT 1", [chapter_id] + ) + row = cursor.fetchone() + return row[0] if row else None + + +def _increment_comment_count(comic_id): + """Atomically increment comic.comment_count by 1.""" + Comic.objects.filter(id=comic_id).update( + comment_count=models.F('comment_count') + 1 + ) + + +def _decrement_comment_count(comic_id): + """Atomically decrement comic.comment_count by 1 (min 0).""" + with connection.cursor() as cursor: + cursor.execute( + "UPDATE comic SET comment_count = GREATEST(comment_count - 1, 0) WHERE id = %s", + [comic_id], + ) + + +# Need to import models for F expression +from django.db import models + + +class ComicCommentListAPI(APIView): + """ + GET /comments/comic//list/ — list top-level comments with replies + """ + + @extend_schema(tags=['muComics']) + def get(self, request, comic_id): + comic = Comic.objects.filter(id=comic_id, deleted_at__isnull=True).first() + if not comic: + return CustomResponse( + general_message="Comic not found" + ).get_failure_response(status_code=404, http_status_code=404) + + user_id = _get_user_id_or_none(request) + + # Filter: active comments OR soft-deleted comments that have active replies + active_replies = ComicComment.objects.filter( + parent_id=OuterRef('pk'), + deleted_at__isnull=True + ) + + # Prefetch replies for the serializer + prefetch_replies = Prefetch( + 'replies', + queryset=ComicComment.objects.filter( + deleted_at__isnull=True + ).select_related('user').order_by('created_at'), + to_attr='active_replies' + ) + + queryset = ComicComment.objects.filter( + comic_id=comic_id, + parent__isnull=True, + ).annotate( + has_active_replies=Exists(active_replies) + ).filter( + Q(deleted_at__isnull=True) | Q(has_active_replies=True) + ).select_related('user').prefetch_related(prefetch_replies).order_by('-created_at') + + paginated = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=['message'], + sort_fields={'created_at': 'created_at'}, + ) + + serializer = CommentListSerializer( + paginated['queryset'], many=True, + context={'user_id': user_id}, + ) + + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class ComicCommentCreateAPI(APIView): + """ + POST /comments/comic//create/ — create a comment (top-level or reply) + """ + permission_classes = [CustomizePermission] + + @extend_schema(tags=['muComics']) + def post(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + + comic = Comic.objects.filter(id=comic_id, deleted_at__isnull=True).first() + if not comic: + return CustomResponse( + general_message="Comic not found" + ).get_failure_response(status_code=404, http_status_code=404) + + serializer = CommentCreateSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + message=serializer.errors + ).get_failure_response() + + message = serializer.validated_data['message'] + parent_id = serializer.validated_data.get('parent_id') + + # Validate parent_id + if parent_id: + try: + parent = ComicComment.objects.get(id=parent_id) + except ComicComment.DoesNotExist: + return CustomResponse( + message={"parent_id": ["Parent comment not found"]} + ).get_failure_response() + + if parent.parent_id is not None: + return CustomResponse( + message={"parent_id": ["Replies can only be made to top-level comments"]} + ).get_failure_response() + + if parent.comic_id != comic_id: + return CustomResponse( + message={"parent_id": ["Parent comment does not belong to this comic"]} + ).get_failure_response() + + if parent.is_deleted: + return CustomResponse( + message={"parent_id": ["Cannot reply to a deleted comment"]} + ).get_failure_response() + + if parent.chapter_id is not None: + return CustomResponse( + message={"parent_id": ["Replies to chapter comments must use the chapter endpoint"]} + ).get_failure_response() + + now = timezone.now() + with transaction.atomic(): + comment = ComicComment.objects.create( + id=str(uuid.uuid4()), + comic_id=comic_id, + chapter_id=None, + parent_id=parent_id, + user_id=user_id, + message=message, + updated_by_id=user_id, + updated_at=now, + created_by_id=user_id, + created_at=now, + ) + + _increment_comment_count(comic_id) + + from db.user import User + user = User.every.get(id=user_id) + + return CustomResponse( + general_message="Comment created successfully", + response={ + "id": comment.id, + "comic_id": comment.comic_id, + "chapter_id": comment.chapter_id, + "parent_id": comment.parent_id, + "user": CommentUserSerializer(user).data, + "message": comment.message, + "is_edited": False, + "created_at": comment.created_at, + "updated_at": comment.updated_at, + }, + ).get_success_response() + + +class ChapterCommentListAPI(APIView): + """ + GET /comments/chapter//list/ — list chapter comments + """ + + @extend_schema(tags=['muComics']) + def get(self, request, chapter_id): + comic_id = _chapter_exists(chapter_id) + if not comic_id: + return CustomResponse( + general_message="Chapter not found" + ).get_failure_response(status_code=404, http_status_code=404) + + user_id = _get_user_id_or_none(request) + + # Filter: active comments OR soft-deleted comments that have active replies + active_replies = ComicComment.objects.filter( + parent_id=OuterRef('pk'), + deleted_at__isnull=True + ) + + # Prefetch replies for the serializer + prefetch_replies = Prefetch( + 'replies', + queryset=ComicComment.objects.filter( + deleted_at__isnull=True + ).select_related('user').order_by('created_at'), + to_attr='active_replies' + ) + + queryset = ComicComment.objects.filter( + chapter_id=chapter_id, + parent__isnull=True, + ).annotate( + has_active_replies=Exists(active_replies) + ).filter( + Q(deleted_at__isnull=True) | Q(has_active_replies=True) + ).select_related('user').prefetch_related(prefetch_replies).order_by('-created_at') + + paginated = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=['message'], + sort_fields={'created_at': 'created_at'}, + ) + + serializer = CommentListSerializer( + paginated['queryset'], many=True, + context={'user_id': user_id}, + ) + + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated['pagination'], + ) + + +class ChapterCommentCreateAPI(APIView): + """ + POST /comments/chapter//create/ — create chapter comment + """ + permission_classes = [CustomizePermission] + + @extend_schema(tags=['muComics']) + def post(self, request, chapter_id): + user_id = JWTUtils.fetch_user_id(request) + + comic_id = _chapter_exists(chapter_id) + if not comic_id: + return CustomResponse( + general_message="Chapter not found" + ).get_failure_response(status_code=404, http_status_code=404) + + serializer = CommentCreateSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + message=serializer.errors + ).get_failure_response() + + message = serializer.validated_data['message'] + parent_id = serializer.validated_data.get('parent_id') + + if parent_id: + try: + parent = ComicComment.objects.get(id=parent_id) + except ComicComment.DoesNotExist: + return CustomResponse( + message={"parent_id": ["Parent comment not found"]} + ).get_failure_response() + + if parent.parent_id is not None: + return CustomResponse( + message={"parent_id": ["Replies can only be made to top-level comments"]} + ).get_failure_response() + + if parent.chapter_id != chapter_id: + return CustomResponse( + message={"parent_id": ["Parent comment does not belong to this chapter"]} + ).get_failure_response() + + if parent.is_deleted: + return CustomResponse( + message={"parent_id": ["Cannot reply to a deleted comment"]} + ).get_failure_response() + + now = timezone.now() + with transaction.atomic(): + comment = ComicComment.objects.create( + id=str(uuid.uuid4()), + comic_id=comic_id, + chapter_id=chapter_id, + parent_id=parent_id, + user_id=user_id, + message=message, + updated_by_id=user_id, + updated_at=now, + created_by_id=user_id, + created_at=now, + ) + + _increment_comment_count(comic_id) + + from db.user import User + user = User.every.get(id=user_id) + + return CustomResponse( + general_message="Comment created successfully", + response={ + "id": comment.id, + "comic_id": comment.comic_id, + "chapter_id": comment.chapter_id, + "parent_id": comment.parent_id, + "user": CommentUserSerializer(user).data, + "message": comment.message, + "is_edited": False, + "created_at": comment.created_at, + "updated_at": comment.updated_at, + }, + ).get_success_response() + + +class CommentDetailAPI(APIView): + """ + PATCH /comments// — edit own comment + DELETE /comments// — soft-delete own comment + """ + permission_classes = [CustomizePermission] + + @extend_schema(tags=['muComics']) + def patch(self, request, comment_id): + user_id = JWTUtils.fetch_user_id(request) + + try: + comment = ComicComment.objects.get(id=comment_id) + except ComicComment.DoesNotExist: + return CustomResponse( + general_message="Comment not found" + ).get_failure_response(status_code=404, http_status_code=404) + + if comment.is_deleted: + return CustomResponse( + general_message="Cannot edit a deleted comment" + ).get_failure_response() + + if comment.user_id != user_id: + return CustomResponse( + general_message="You can only edit your own comments" + ).get_failure_response(status_code=403, http_status_code=403) + + serializer = CommentUpdateSerializer(data=request.data) + if not serializer.is_valid(): + return CustomResponse( + message=serializer.errors + ).get_failure_response() + + now = timezone.now() + comment.message = serializer.validated_data['message'] + comment.updated_by_id = user_id + comment.updated_at = now + comment.save() + + return CustomResponse( + general_message="Comment updated successfully", + response={ + "id": comment.id, + "message": comment.message, + "is_edited": True, + "updated_at": comment.updated_at, + }, + ).get_success_response() + + @extend_schema(tags=['muComics']) + def delete(self, request, comment_id): + user_id = JWTUtils.fetch_user_id(request) + + try: + comment = ComicComment.objects.get(id=comment_id) + except ComicComment.DoesNotExist: + return CustomResponse( + general_message="Comment not found" + ).get_failure_response(status_code=404, http_status_code=404) + + if comment.is_deleted: + return CustomResponse( + general_message="Comment has already been deleted" + ).get_failure_response() + + if comment.user_id != user_id: + return CustomResponse( + general_message="You can only delete your own comments" + ).get_failure_response(status_code=403, http_status_code=403) + + now = timezone.now() + with transaction.atomic(): + comment.deleted_at = now + comment.deleted_by_id = user_id + comment.updated_at = now + comment.updated_by_id = user_id + comment.save() + + _decrement_comment_count(comment.comic_id) + + return CustomResponse( + general_message="Comment deleted successfully" + ).get_success_response() diff --git a/api/muComics/comment/urls.py b/api/muComics/comment/urls.py new file mode 100644 index 000000000..1accf583c --- /dev/null +++ b/api/muComics/comment/urls.py @@ -0,0 +1,50 @@ +from django.urls import path + +from . import comment_views, admin_views + +urlpatterns = [ + # Comic Comments + path( + 'comic//list/', + comment_views.ComicCommentListAPI.as_view(), + name='comic-comment-list', + ), + path( + 'comic//create/', + comment_views.ComicCommentCreateAPI.as_view(), + name='comic-comment-create', + ), + + # Chapter Comments + path( + 'chapter//list/', + comment_views.ChapterCommentListAPI.as_view(), + name='chapter-comment-list', + ), + path( + 'chapter//create/', + comment_views.ChapterCommentCreateAPI.as_view(), + name='chapter-comment-create', + ), + + # Admin — List All Comments + path( + 'admin/', + admin_views.AdminCommentListAPI.as_view(), + name='admin-comment-list', + ), + + # Admin — Delete Comment (Soft-Delete) + path( + 'admin//', + admin_views.AdminCommentDeleteAPI.as_view(), + name='admin-comment-delete', + ), + + # Edit / Delete (User Soft-Delete) + path( + '/', + comment_views.CommentDetailAPI.as_view(), + name='comment-detail', + ), +] diff --git a/api/muComics/reader/__init__.py b/api/muComics/reader/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/muComics/reader/dashboard_views.py b/api/muComics/reader/dashboard_views.py new file mode 100644 index 000000000..4c7f67026 --- /dev/null +++ b/api/muComics/reader/dashboard_views.py @@ -0,0 +1,96 @@ +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from db.comic import ComicLikeLink, ComicBookmarkLink, ComicReadingProgress +from db.user import User +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse +from utils.utils import CommonUtils + +from api.muComics.comic.serializers import MinimalUserSerializer +from .serializers import PaginatedBookmarkSerializer, PaginatedProgressSerializer + + +class ReaderDashboardAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=["Comic Reader"]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + user = User.objects.filter(id=user_id).first() + if not user: + return CustomResponse(general_message="User not found").get_failure_response(status_code=404, http_status_code=404) + + stats = { + "total_likes": ComicLikeLink.objects.filter(user_id=user_id).count(), + "total_bookmarks": ComicBookmarkLink.objects.filter(user_id=user_id).count(), + "in_progress": ComicReadingProgress.objects.filter(user_id=user_id).count() + } + + return CustomResponse( + response={ + "user": MinimalUserSerializer(user).data, + "stats": stats + } + ).get_success_response() + + +class MyBookmarksAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=["Comic Reader"]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + queryset = ComicBookmarkLink.objects.filter( + user_id=user_id, + comic__deleted_at__isnull=True + ).select_related('comic').order_by('-created_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=["comic__title", "comic__slug"] + ) + + serializer = PaginatedBookmarkSerializer( + paginated_queryset.get("queryset"), + many=True, + context={'request': request} + ) + + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated_queryset.get("pagination") + ) + + +class MyReadingProgressAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=["Comic Reader"]) + def get(self, request): + user_id = JWTUtils.fetch_user_id(request) + + queryset = ComicReadingProgress.objects.filter( + user_id=user_id, + comic__deleted_at__isnull=True + ).select_related('comic', 'last_chapter').order_by('-updated_at') + + paginated_queryset = CommonUtils.get_paginated_queryset( + queryset, + request, + search_fields=["comic__title", "comic__slug"] + ) + + serializer = PaginatedProgressSerializer( + paginated_queryset.get("queryset"), + many=True, + context={'request': request} + ) + + return CustomResponse().paginated_response( + data=serializer.data, + pagination=paginated_queryset.get("pagination") + ) diff --git a/api/muComics/reader/interaction_views.py b/api/muComics/reader/interaction_views.py new file mode 100644 index 000000000..fca625996 --- /dev/null +++ b/api/muComics/reader/interaction_views.py @@ -0,0 +1,108 @@ +from django.db import transaction, IntegrityError +from django.db.models import F, Value +from django.db.models.functions import Greatest +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from db.comic import Comic, ComicLikeLink, ComicBookmarkLink +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse + +from .serializers import ComicLikeSerializer, ComicBookmarkSerializer + + +class LikeComicAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=["Comic Reader"]) + def post(self, request, comic_id): + # We pass comic_id into the serializer data for validation + serializer = ComicLikeSerializer( + data={'comic_id': comic_id}, + context={'request': request} + ) + + if serializer.is_valid(): + try: + with transaction.atomic(): + serializer.save() + Comic.objects.filter(id=comic_id).update(like_count=F('like_count') + 1) + return CustomResponse(response={"message": "Comic liked successfully"}).get_success_response() + except IntegrityError: + return CustomResponse( + general_message="Comic already liked." + ).get_failure_response(status_code=409, http_status_code=409) + + return CustomResponse( + general_message="Validation failed.", + response=serializer.errors + ).get_failure_response() + + @extend_schema(tags=["Comic Reader"]) + def delete(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + + with transaction.atomic(): + deleted, _ = ComicLikeLink.objects.filter(comic_id=comic_id, user_id=user_id).delete() + if deleted > 0: + Comic.objects.filter(id=comic_id).update(like_count=Greatest(F('like_count') - 1, Value(0))) + return CustomResponse(response={"message": "Comic unliked successfully"}).get_success_response() + else: + return CustomResponse(general_message="Comic like not found.").get_failure_response(status_code=404, http_status_code=404) + + +class BookmarkComicAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=["Comic Reader"]) + def post(self, request, comic_id): + serializer = ComicBookmarkSerializer( + data={'comic_id': comic_id}, + context={'request': request} + ) + + if serializer.is_valid(): + try: + with transaction.atomic(): + serializer.save() + Comic.objects.filter(id=comic_id).update(bookmark_count=F('bookmark_count') + 1) + return CustomResponse(response={"message": "Comic bookmarked successfully"}).get_success_response() + except IntegrityError: + return CustomResponse( + general_message="Comic already bookmarked." + ).get_failure_response(status_code=409, http_status_code=409) + + return CustomResponse( + general_message="Validation failed.", + response=serializer.errors + ).get_failure_response() + + @extend_schema(tags=["Comic Reader"]) + def delete(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + + with transaction.atomic(): + deleted, _ = ComicBookmarkLink.objects.filter(comic_id=comic_id, user_id=user_id).delete() + if deleted > 0: + Comic.objects.filter(id=comic_id).update(bookmark_count=Greatest(F('bookmark_count') - 1, Value(0))) + return CustomResponse(response={"message": "Comic bookmark removed successfully"}).get_success_response() + else: + return CustomResponse(general_message="Comic bookmark not found.").get_failure_response(status_code=404, http_status_code=404) + + +class InteractionStatusAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=["Comic Reader"]) + def get(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + + is_liked = ComicLikeLink.objects.filter(comic_id=comic_id, user_id=user_id).exists() + is_bookmarked = ComicBookmarkLink.objects.filter(comic_id=comic_id, user_id=user_id).exists() + + return CustomResponse( + response={ + "is_liked": is_liked, + "is_bookmarked": is_bookmarked + } + ).get_success_response() diff --git a/api/muComics/reader/progress_views.py b/api/muComics/reader/progress_views.py new file mode 100644 index 000000000..df8f8ea27 --- /dev/null +++ b/api/muComics/reader/progress_views.py @@ -0,0 +1,64 @@ +from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema + +from db.comic import ComicReadingProgress +from utils.permission import CustomizePermission, JWTUtils +from utils.response import CustomResponse + +from .serializers import ComicReadingProgressSerializer + + +class ReadingProgressAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema(tags=["Comic Reader"]) + def put(self, request, comic_id): + data = request.data.copy() + data['comic_id'] = comic_id + + serializer = ComicReadingProgressSerializer( + data=data, + context={'request': request} + ) + + if serializer.is_valid(): + progress = serializer.save() + return CustomResponse( + response={ + "id": progress.id, + "comic_id": progress.comic_id, + "last_chapter_id": progress.last_chapter_id, + "last_page_number": progress.last_page_number, + "updated_at": progress.updated_at + }, + general_message="Reading progress saved successfully" + ).get_success_response() + + return CustomResponse( + general_message="Validation failed.", + response=serializer.errors + ).get_failure_response() + + @extend_schema(tags=["Comic Reader"]) + def get(self, request, comic_id): + user_id = JWTUtils.fetch_user_id(request) + + progress = ComicReadingProgress.objects.filter( + comic_id=comic_id, + user_id=user_id + ).first() + + if progress: + response_data = { + "id": progress.id, + "comic_id": progress.comic_id, + "last_chapter_id": progress.last_chapter_id, + "last_page_number": progress.last_page_number, + "updated_at": progress.updated_at + } + else: + response_data = None + + return CustomResponse( + response=response_data + ).get_success_response() diff --git a/api/muComics/reader/serializers.py b/api/muComics/reader/serializers.py new file mode 100644 index 000000000..b82fc7b97 --- /dev/null +++ b/api/muComics/reader/serializers.py @@ -0,0 +1,156 @@ +from django.db import transaction, IntegrityError +from django.db.models import F +from django.utils import timezone +from rest_framework import serializers + +from db.comic import Comic, ComicLikeLink, ComicBookmarkLink, ComicReadingProgress, Chapter +from utils.permission import JWTUtils + + +class ComicInteractionValidationMixin: + """Mixin to validate that a comic is published and active.""" + + def validate_comic_id(self, value): + comic = Comic.objects.filter( + id=value, + status=Comic.Status.PUBLISHED, + deleted_at__isnull=True + ).first() + if not comic: + raise serializers.ValidationError("Comic not found or not available.") + return value + + +class ComicLikeSerializer(ComicInteractionValidationMixin, serializers.ModelSerializer): + comic_id = serializers.CharField(required=True) + + class Meta: + model = ComicLikeLink + fields = ['comic_id'] + + def create(self, validated_data): + comic_id = validated_data['comic_id'] + user_id = JWTUtils.fetch_user_id(self.context.get('request')) + + # We allow IntegrityError to bubble up to the view where it is caught and returned as 409 + like = ComicLikeLink.objects.create( + comic_id=comic_id, + user_id=user_id, + created_by_id=user_id, + created_at=timezone.now() + ) + return like + + +class ComicBookmarkSerializer(ComicInteractionValidationMixin, serializers.ModelSerializer): + comic_id = serializers.CharField(required=True) + + class Meta: + model = ComicBookmarkLink + fields = ['comic_id'] + + def create(self, validated_data): + comic_id = validated_data['comic_id'] + user_id = JWTUtils.fetch_user_id(self.context.get('request')) + + bookmark = ComicBookmarkLink.objects.create( + comic_id=comic_id, + user_id=user_id, + created_by_id=user_id, + created_at=timezone.now() + ) + return bookmark + + +class ComicReadingProgressSerializer(ComicInteractionValidationMixin, serializers.ModelSerializer): + comic_id = serializers.CharField(required=True) + last_chapter_id = serializers.CharField(required=False, allow_null=True) + last_page_number = serializers.IntegerField(required=False, allow_null=True) + + class Meta: + model = ComicReadingProgress + fields = ['comic_id', 'last_chapter_id', 'last_page_number'] + + def validate(self, attrs): + # We already validated comic_id in validate_comic_id via the mixin + comic_id = attrs.get('comic_id') + last_chapter_id = attrs.get('last_chapter_id') + last_page_number = attrs.get('last_page_number') + + if last_chapter_id: + # Verify chapter belongs to this comic and is published/active + chapter = Chapter.objects.filter( + id=last_chapter_id, + comic_id=comic_id, + status=Comic.Status.PUBLISHED, + deleted_at__isnull=True + ).first() + if not chapter: + raise serializers.ValidationError({"last_chapter_id": "Chapter not found, not published, or does not belong to this comic."}) + + if last_page_number is not None: + # Verify page exists in that chapter and is active + page_exists = chapter.pages.filter( + page_number=last_page_number, + deleted_at__isnull=True + ).exists() + if not page_exists: + raise serializers.ValidationError({"last_page_number": "Page not found in this chapter."}) + elif last_page_number is not None: + raise serializers.ValidationError({"last_page_number": "Cannot provide page number without chapter."}) + + return attrs + + def create(self, validated_data): + comic_id = validated_data['comic_id'] + user_id = JWTUtils.fetch_user_id(self.context.get('request')) + last_chapter_id = validated_data.get('last_chapter_id') + last_page_number = validated_data.get('last_page_number') + now = timezone.now() + + try: + with transaction.atomic(): + progress = ComicReadingProgress.objects.create( + user_id=user_id, + comic_id=comic_id, + last_chapter_id=last_chapter_id, + last_page_number=last_page_number, + updated_at=now, + created_at=now + ) + except IntegrityError: + with transaction.atomic(): + progress = ComicReadingProgress.objects.select_for_update().get(user_id=user_id, comic_id=comic_id) + progress.last_chapter_id = last_chapter_id + progress.last_page_number = last_page_number + progress.updated_at = now + progress.save(update_fields=['last_chapter_id', 'last_page_number', 'updated_at']) + + return progress + + +class PaginatedBookmarkSerializer(serializers.ModelSerializer): + comic_id = serializers.CharField(source='comic.id') + title = serializers.CharField(source='comic.title') + slug = serializers.CharField(source='comic.slug') + cover_image_key = serializers.CharField(source='comic.cover_image_key') + bookmarked_at = serializers.DateTimeField(source='created_at') + + class Meta: + model = ComicBookmarkLink + fields = ['id', 'comic_id', 'title', 'slug', 'cover_image_key', 'bookmarked_at'] + + +class PaginatedProgressSerializer(serializers.ModelSerializer): + comic_id = serializers.CharField(source='comic.id') + title = serializers.CharField(source='comic.title') + slug = serializers.CharField(source='comic.slug') + cover_image_key = serializers.CharField(source='comic.cover_image_key') + last_read_at = serializers.DateTimeField(source='updated_at') + + class Meta: + model = ComicReadingProgress + fields = [ + 'id', 'comic_id', 'title', 'slug', 'cover_image_key', + 'last_chapter_id', 'last_page_number', 'last_read_at' + ] diff --git a/api/muComics/reader/urls.py b/api/muComics/reader/urls.py new file mode 100644 index 000000000..bc3f8164f --- /dev/null +++ b/api/muComics/reader/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from . import interaction_views, progress_views, dashboard_views + +urlpatterns = [ + # Dashboard + path('me/', dashboard_views.ReaderDashboardAPI.as_view()), + path('me/bookmarks/', dashboard_views.MyBookmarksAPI.as_view()), + path('me/progress/', dashboard_views.MyReadingProgressAPI.as_view()), + + # Interactions + path('comics//likes/', interaction_views.LikeComicAPI.as_view()), + path('comics//bookmarks/', interaction_views.BookmarkComicAPI.as_view()), + path('comics//interaction-status/', interaction_views.InteractionStatusAPI.as_view()), + + # Progress + path('comics//progress/', progress_views.ReadingProgressAPI.as_view()), +] diff --git a/api/muComics/urls.py b/api/muComics/urls.py new file mode 100644 index 000000000..6c59ed7fa --- /dev/null +++ b/api/muComics/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include + +urlpatterns = [ + path('comics/', include('api.muComics.comic.urls')), + path('comments/', include('api.muComics.comment.urls')), + path('chapters/', include('api.muComics.chapter.urls')), + path('reader/', include('api.muComics.reader.urls')), +] diff --git a/api/notification/broadcast_utils.py b/api/notification/broadcast_utils.py new file mode 100644 index 000000000..05d712ad3 --- /dev/null +++ b/api/notification/broadcast_utils.py @@ -0,0 +1,70 @@ +from datetime import timedelta + +from db.notification import BroadcastNotification +from utils.utils import DateTimeUtils + + +# Expiry windows as specified in the implementation doc +EXPIRY_DAYS = { + 'event_published': 7, + 'event_cancelled': 3, + 'collab_response': 5, + 'lc_created': 7, +} + + +class BroadcastUtils: + """ + Utility class for creating BroadcastNotification records. + + A single row is written per broadcast event — recipients are resolved + dynamically at read time based on target_type + target_id. + + target_type reference: + 'campus' -> all users in that campus org (target_id = org_id) + 'interest_group' -> all active learner members of that IG (target_id = ig_id) + 'campus_ig' -> all learners in a campus-IG chapter (target_id = campus_ig_composite_id) + 'event_interest' -> all users who expressed interest in the event (target_id = event_id) + 'event_coowners' -> event creator + co-owners (target_id = event_id) + 'global' -> all active users (target_id = None) + """ + + @staticmethod + def create_broadcast( + title: str, + description: str, + target_type: str, + created_by, + expiry_key: str, + url: str = None, + target_id: str = None, + ) -> BroadcastNotification: + """ + Create and persist a single BroadcastNotification row. + + Args: + title: Short headline (max 50 chars). + description: Body text (max 200 chars). + target_type: Audience category — see class docstring. + created_by: User instance that triggered the action. + expiry_key: One of the keys in EXPIRY_DAYS. + url: Deep-link URL (optional). + target_id: ID of the target entity (None for 'global'). + + Returns: + The created BroadcastNotification instance. + """ + now = DateTimeUtils.get_current_utc_time() + expiry_days = EXPIRY_DAYS.get(expiry_key, 7) + expires_at = now + timedelta(days=expiry_days) + + return BroadcastNotification.objects.create( + title=title, + description=description, + url=url, + target_type=target_type, + target_id=str(target_id) if target_id else None, + created_by=created_by, + created_at=now, + expires_at=expires_at, + ) diff --git a/api/notification/notification_view.py b/api/notification/notification_view.py index e5585b812..5a2e9b8cd 100644 --- a/api/notification/notification_view.py +++ b/api/notification/notification_view.py @@ -1,42 +1,145 @@ +from django.utils import timezone from rest_framework.views import APIView -from db.notification import Notification -from utils.permission import CustomizePermission, JWTUtils +from db.notification import Notification, BroadcastNotification +from db.events import EventConnection, EventInterest +from utils.permission import CustomizePermission, JWTUtils, role_required from utils.response import CustomResponse +from utils.types import RoleType from . import serializers +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s + + +def _resolve_user_broadcasts(user_id: str): + """ + Return active (non-expired) BroadcastNotification rows that the user belongs to. + + Audience matching logic per target_type: + 'global' -> always included + 'campus' -> user is linked to that org (UserOrganizationLink) + 'interest_group' -> user has an active LEARNER link to that IG (UserIgLink) + 'campus_ig' -> user has an active LEARNER link and is in that campus org + 'event_interest' -> user has expressed interest in that event (EventInterest) + 'event_coowners' -> user is the event creator OR a CO_OWNER EventConnection + """ + from db.organization import UserOrganizationLink + from db.task import UserIgLink + + now = timezone.now() + active_broadcasts = BroadcastNotification.objects.filter(expires_at__gt=now) + + # --- Collect the user's memberships for efficient filtering --- + + # Campus org IDs + campus_org_ids = set( + UserOrganizationLink.objects.filter( + user_id=user_id, verified=True + ).values_list('org_id', flat=True) + ) + + # Active learner IG IDs + learner_ig_ids = set( + UserIgLink.objects.filter( + user_id=user_id, + assignment_type=UserIgLink.AssignmentType.LEARNER, + is_active=True, + ).values_list('ig_id', flat=True) + ) + + # Events the user expressed interest in + interested_event_ids = set( + EventInterest.objects.filter(user_id=user_id).values_list('event_id', flat=True) + ) + + # Events the user co-owns + coowned_event_ids = set( + EventConnection.objects.filter( + entity_id=user_id, + entity_type=EventConnection.EntityType.CO_OWNER, + ).values_list('event_id', flat=True) + ) + + # Events the user created + from db.events import Event + created_event_ids = set( + Event.objects.filter(created_by_id=user_id).values_list('id', flat=True) + ) + + # All events where user is creator or co-owner (for event_coowners type) + event_coowner_ids = coowned_event_ids | created_event_ids + + matched = [] + for b in active_broadcasts: + tt = b.target_type + tid = b.target_id + + if tt == 'global': + matched.append(b) + elif tt == 'campus' and tid in campus_org_ids: + matched.append(b) + elif tt == 'interest_group' and tid in learner_ig_ids: + matched.append(b) + elif tt == 'campus_ig': + # Resolve the CampusIGChapter to check if the user is in both the org and the ig of that chapter + from db.campus import CampusIGChapter + chapter = CampusIGChapter.objects.filter(id=tid).first() + if chapter and chapter.org_id in campus_org_ids and chapter.ig_id in learner_ig_ids: + matched.append(b) + elif tt == 'event_interest' and tid in interested_event_ids: + matched.append(b) + elif tt == 'event_coowners' and tid in event_coowner_ids: + matched.append(b) + + return matched class NotificationListsAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Notification'], + description=( + "Retrieve all notifications for the authenticated user. " + "Returns two lists: `direct` (personal) and `broadcasts` (group/audience)." + ), + responses={200: inline_serializer( + name='NotificationListResponse', + fields={ + 'direct': serializers.NotificationSerializer(many=True), + 'broadcasts': serializers.BroadcastNotificationSerializer(many=True), + }, + )}, + ) def get(self, request): """ - Get all notifications for a user - Args: - request: - Returns: - 200: List of notifications - + direct: Personal notifications addressed to this user. + broadcasts: Active group-wide notifications the user qualifies for, + resolved by audience membership at read time. """ user_id = JWTUtils.fetch_user_id(request) - notification_list = Notification.objects.filter(user_id=user_id) - response = serializers.NotificationSerializer(notification_list, many=True).data - return CustomResponse(response=response).get_success_response() + + direct_qs = Notification.objects.filter(user_id=user_id) + broadcast_list = _resolve_user_broadcasts(user_id) + + return CustomResponse(response={ + 'direct': serializers.NotificationSerializer(direct_qs, many=True).data, + 'broadcasts': serializers.BroadcastNotificationSerializer(broadcast_list, many=True).data, + }).get_success_response() class NotificationDeleteAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Notification'], description="Delete Notification Delete.", + responses={200: serializers.NotificationSerializer}, + ) def delete(self, request, notification_id): """ - Delete notification by providing notification id - Args: - notification_id: - request: 'notification_id' - - Returns: - 200: Notification deleted successfully - 400: Notification not found - + Delete a single direct notification by its ID. + Broadcast notifications cannot be individually deleted; + they expire automatically via their expires_at timestamp. """ user_id = JWTUtils.fetch_user_id(request) notification = Notification.objects.filter(user_id=user_id, id=notification_id) @@ -50,16 +153,13 @@ def delete(self, request, notification_id): class NotificationDeleteAllAPI(APIView): authentication_classes = [CustomizePermission] + @extend_schema(tags=['Notification'], description="Delete Notification Delete All.", + responses={200: serializers.NotificationSerializer}, + ) def delete(self, request): """ - Delete all the notifications for a user - Args: - request: - - Returns: - 200: Notification deleted successfully - 400: Notification not found - + Delete all direct notifications for the authenticated user. + Broadcast notifications are unaffected. """ user_id = JWTUtils.fetch_user_id(request) notification = Notification.objects.filter(user_id=user_id) @@ -68,3 +168,172 @@ def delete(self, request): notification.delete() return CustomResponse(general_message='All notification deleted successfully').get_success_response() + + +class BroadcastNotificationDeleteAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Notification'], + description="Delete a single broadcast notification by its ID. Admin role required.", + responses={200: serializers.BroadcastNotificationSerializer}, + ) + @role_required([RoleType.ADMIN.value]) + def delete(self, request, broadcast_id): + """ + Delete a single BroadcastNotification by its ID. + Only accessible to users with the Admin role. + """ + try: + broadcast = BroadcastNotification.objects.get(id=broadcast_id) + except BroadcastNotification.DoesNotExist: + return CustomResponse(general_message='Broadcast notification not found').get_failure_response() + + broadcast.delete() + return CustomResponse(general_message='Broadcast notification deleted successfully').get_success_response() + + +class BroadcastNotificationDeleteAllAPI(APIView): + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Notification'], + description="Delete all broadcast notifications. Admin role required.", + responses={200: serializers.BroadcastNotificationSerializer}, + ) + @role_required([RoleType.ADMIN.value]) + def delete(self, request): + """ + Delete ALL BroadcastNotification records. + Only accessible to users with the Admin role. + """ + broadcasts = BroadcastNotification.objects.all() + if not broadcasts.exists(): + return CustomResponse(general_message='No broadcast notifications to delete').get_failure_response() + + count = broadcasts.count() + broadcasts.delete() + return CustomResponse( + general_message=f'All {count} broadcast notification(s) deleted successfully' + ).get_success_response() + + +# ───────────────────────────────────────────────────────────────────────────── +# ADMIN BROADCAST CRUD — List / Create / Update +# ───────────────────────────────────────────────────────────────────────────── + +class BroadcastNotificationListAPI(APIView): + """ + GET /api/v1/notification/broadcast/list/all/ + Admin-only: return all BroadcastNotification records with resolved target details. + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Notification'], + description=( + "Admin only. List all broadcast notifications. " + "Each record includes a `target_details` field with the resolved " + "human-readable name of the target audience." + ), + responses={200: serializers.BroadcastNotificationAdminSerializer(many=True)}, + ) + @role_required([RoleType.ADMIN.value]) + def get(self, request): + broadcasts = BroadcastNotification.objects.select_related('created_by').all() + data = serializers.BroadcastNotificationAdminSerializer(broadcasts, many=True).data + return CustomResponse(response=data).get_success_response() + + +class BroadcastNotificationCreateAPI(APIView): + """ + POST /api/v1/notification/broadcast/create/ + Admin-only: create a new global BroadcastNotification announcement. + + Required body fields: + title (str, max 50) + description (str, max 200) + expires_at (datetime ISO-8601) + Optional: + url (str) — deep-link path (max 100) + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Notification'], + description="Admin only. Create a new global broadcast announcement.", + responses={201: serializers.BroadcastNotificationAdminSerializer}, + ) + @role_required([RoleType.ADMIN.value]) + def post(self, request): + user_id = JWTUtils.fetch_user_id(request) + write_serializer = serializers.BroadcastNotificationWriteSerializer(data=request.data) + + if not write_serializer.is_valid(): + return CustomResponse(general_message=write_serializer.errors).get_failure_response() + + from db.user import User as UserModel + creator = UserModel.objects.filter(id=user_id).first() + if not creator: + return CustomResponse(general_message='User not found').get_failure_response() + + from utils.utils import DateTimeUtils + now = DateTimeUtils.get_current_utc_time() + + broadcast = BroadcastNotification.objects.create( + title=write_serializer.validated_data['title'], + description=write_serializer.validated_data['description'], + url=write_serializer.validated_data.get('url'), + target_type='global', + target_id=None, + created_by=creator, + created_at=now, + expires_at=write_serializer.validated_data['expires_at'], + ) + + response_data = serializers.BroadcastNotificationAdminSerializer(broadcast).data + return CustomResponse( + general_message='Broadcast notification created successfully.', + response=response_data, + ).get_success_response() + + +class BroadcastNotificationUpdateAPI(APIView): + """ + PATCH /api/v1/notification/broadcast/update/id// + Admin-only: partially update an existing BroadcastNotification. + All body fields are optional (partial=True). + """ + authentication_classes = [CustomizePermission] + + @extend_schema( + tags=['Notification'], + description=( + "Admin only. Partially update a broadcast notification by its ID. " + "All fields are optional." + ), + responses={200: serializers.BroadcastNotificationAdminSerializer}, + ) + @role_required([RoleType.ADMIN.value]) + def patch(self, request, broadcast_id): + try: + broadcast = BroadcastNotification.objects.get(id=broadcast_id) + except BroadcastNotification.DoesNotExist: + return CustomResponse(general_message='Broadcast notification not found').get_failure_response() + + write_serializer = serializers.BroadcastNotificationWriteSerializer( + instance=broadcast, + data=request.data, + partial=True, + ) + + if not write_serializer.is_valid(): + return CustomResponse(general_message=write_serializer.errors).get_failure_response() + + write_serializer.save() + + response_data = serializers.BroadcastNotificationAdminSerializer(broadcast).data + return CustomResponse( + general_message='Broadcast notification updated successfully.', + response=response_data, + ).get_success_response() diff --git a/api/notification/serializers.py b/api/notification/serializers.py index 83a268e5b..e17377366 100644 --- a/api/notification/serializers.py +++ b/api/notification/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from db.notification import Notification +from db.notification import Notification, BroadcastNotification class NotificationSerializer(serializers.ModelSerializer): @@ -8,3 +8,92 @@ class Meta: model = Notification fields = '__all__' + +class BroadcastNotificationSerializer(serializers.ModelSerializer): + class Meta: + model = BroadcastNotification + fields = [ + 'id', 'title', 'description', 'url', + 'target_type', 'target_id', + 'created_at', 'expires_at', 'created_by', + ] + + +class BroadcastNotificationAdminSerializer(serializers.ModelSerializer): + """ + Extended serializer for admin CRUD on BroadcastNotification. + Adds a `target_details` field that resolves the human-readable name of + the target entity from target_type + target_id. + """ + target_details = serializers.SerializerMethodField() + created_by_name = serializers.CharField(source='created_by.full_name', read_only=True) + + class Meta: + model = BroadcastNotification + fields = [ + 'id', 'title', 'description', 'url', + 'target_type', 'target_id', 'target_details', + 'created_by', 'created_by_name', + 'created_at', 'expires_at', + ] + read_only_fields = ['id', 'created_at', 'created_by', 'created_by_name', 'target_details'] + + def get_target_details(self, obj): + """ + Resolve the human-readable name of the broadcast target. + + Returns a dict with: + - type: the target_type string + - name: resolved entity name (org title, IG name, event title, or 'Global') + """ + tt = obj.target_type + tid = obj.target_id + + if tt == 'global': + return {'type': 'global', 'name': 'Global (All Users)'} + + if not tid: + return {'type': tt, 'name': None} + + try: + if tt == 'campus': + from db.organization import Organization + org = Organization.objects.filter(id=tid).first() + return {'type': 'campus', 'name': org.title if org else f'Unknown Campus ({tid})'} + + if tt == 'interest_group': + from db.task import InterestGroup + ig = InterestGroup.objects.filter(id=tid).first() + return {'type': 'interest_group', 'name': ig.name if ig else f'Unknown IG ({tid})'} + + if tt == 'campus_ig': + from db.campus import CampusIGChapter + chapter = CampusIGChapter.objects.select_related('org', 'ig').filter(id=tid).first() + if chapter: + return { + 'type': 'campus_ig', + 'name': f'{chapter.org.title} — {chapter.ig.name}', + } + return {'type': 'campus_ig', 'name': f'Unknown Chapter ({tid})'} + + if tt in ('event_interest', 'event_coowners'): + from db.events import Event + event = Event.objects.filter(id=tid).first() + return {'type': tt, 'name': event.title if event else f'Unknown Event ({tid})'} + + except Exception: + pass + + return {'type': tt, 'name': tid} + +class BroadcastNotificationWriteSerializer(serializers.ModelSerializer): + """ + Write-only serializer used for Create and Update of BroadcastNotification. + Only allows specifying title, description, url, and expires_at, + forcing target_type and target_id to be set by the view. + """ + class Meta: + model = BroadcastNotification + fields = [ + 'title', 'description', 'url', 'expires_at', + ] diff --git a/api/notification/urls.py b/api/notification/urls.py index eda171652..585df6354 100644 --- a/api/notification/urls.py +++ b/api/notification/urls.py @@ -7,4 +7,16 @@ path('delete/id//', notification_view.NotificationDeleteAPI.as_view(), name='delete-notification'), path('delete/all/', notification_view.NotificationDeleteAllAPI.as_view(), name='delete-all-notification'), + # Broadcast — delete (admin only) + path('broadcast/delete/id//', notification_view.BroadcastNotificationDeleteAPI.as_view(), + name='delete-broadcast-notification'), + path('broadcast/delete/all/', notification_view.BroadcastNotificationDeleteAllAPI.as_view(), + name='delete-all-broadcast-notification'), + # Broadcast — admin CRUD + path('broadcast/list/all/', notification_view.BroadcastNotificationListAPI.as_view(), + name='list-all-broadcast-notifications'), + path('broadcast/create/', notification_view.BroadcastNotificationCreateAPI.as_view(), + name='create-broadcast-notification'), + path('broadcast/update/id//', notification_view.BroadcastNotificationUpdateAPI.as_view(), + name='update-broadcast-notification'), ] diff --git a/api/protected/organisation/organisation_views.py b/api/protected/organisation/organisation_views.py index 8c7479e7c..5d6e9d82d 100644 --- a/api/protected/organisation/organisation_views.py +++ b/api/protected/organisation/organisation_views.py @@ -4,10 +4,16 @@ from db.organization import Organization, District from utils.response import CustomResponse from .serializer import OrganisationSerializer, InstitutesRetrivalSerializer +from drf_spectacular.utils import extend_schema class GetInstitutionsAPI(APIView): + @extend_schema( + tags=['Protected - Organisation'], + description="Retrieve Get Institutions.", + responses={200: OrganisationSerializer}, + ) def get(self, request, organisation_type, district_name): protection_key = request.headers.get("protectionKey") if not protection_key or not protection_key == config('PROTECTED_API_KEY'): @@ -21,6 +27,11 @@ def get(self, request, organisation_type, district_name): class RetrieveInstitutesAPI(APIView): + @extend_schema( + tags=['Protected - Organisation'], + description="Retrieve Retrieve Institutes.", + responses={200: InstitutesRetrivalSerializer}, + ) def get(self, request, district_name): district = District.objects.filter(name=district_name).first() organisations = Organization.objects.filter(org_type__in=["School", "College"], district=district) diff --git a/api/register/register_helper.py b/api/register/register_helper.py index ba2035351..99c1f1dc4 100644 --- a/api/register/register_helper.py +++ b/api/register/register_helper.py @@ -1,33 +1,140 @@ -import decouple -import requests -from db.user import User - -from utils.exception import CustomException -from utils.response import CustomResponse - - - - -def generate_muid(full_name): - full_name = full_name.replace(" ", "").lower()[:85] - muid = f"{full_name}@mulearn" - - counter = 0 - while User.objects.filter(muid=muid).exists(): - counter += 1 - muid = f"{full_name}-{counter}@mulearn" - - return muid - - -def get_auth_token(muid, password): - AUTH_DOMAIN = decouple.config("AUTH_DOMAIN") - response = requests.post( - f"{AUTH_DOMAIN}/api/v1/auth/user-authentication/", - data={"emailOrMuid": muid, "password": password}, - ) - response = response.json() - if response.get("statusCode") != 200: - raise CustomException(response.get("message")) - - return response.get("response") \ No newline at end of file +import decouple +import logging +import requests + +logger = logging.getLogger(__name__) + +from db.user import User + +from utils.exception import CustomException + + +AUTH_TIMEOUT = (3, 10) # (connect seconds, read seconds) — B4 + + +def generate_muid(full_name): + full_name = full_name.replace(" ", "").lower()[:85] + muid = f"{full_name}@mulearn" + + counter = 0 + + while User.every.filter(muid=muid).exists(): + counter += 1 + muid = f"{full_name}-{counter}@mulearn" + + return muid + + +def _post_auth(path, **kwargs): + """ + Thin wrapper around requests.post that: + - Adds a connect + read timeout to every outbound call (B4). + - Converts network-level errors to CustomException so callers + can return a clean error without orphaning DB rows. + """ + AUTH_DOMAIN = decouple.config("AUTH_DOMAIN") + try: + return requests.post( + f"{AUTH_DOMAIN}{path}", timeout=AUTH_TIMEOUT, **kwargs + ) + except requests.RequestException: + raise CustomException( + "Auth service is temporarily unavailable. Please try again." + ) + + +def get_auth_token(muid, password): + resp = _post_auth( + "/api/v1/auth/user-authentication/", + data={"emailOrMuid": muid, "password": password}, + ) + try: + body = resp.json() + except Exception: + logger.error( + "get_auth_token: HTTP %s | non-JSON body: %s", + resp.status_code, resp.text[:500], + ) + raise CustomException( + "Auth service returned an unexpected error. Please try again." + ) + if not resp.ok or body.get("statusCode") != 200: + logger.error( + "get_auth_token failed: HTTP %s | body: %s", + resp.status_code, resp.text[:500], + ) + raise CustomException( + body.get("message") + or "Auth service returned an unexpected error. Please try again." + ) + return body.get("response") + + +def verify_google_temp_token(temp_token): + """ + Verifies a Google tempToken with the auth server. + Returns {'email', 'fullName'} or raises CustomException. + + Parses the body on all HTTP statuses so the auth server's specific error + (expired, already-used, invalid key) is forwarded instead of swallowed. + """ + resp = _post_auth( + "/api/v1/auth/google/verify-temp-token/", + data={"tempToken": temp_token}, + headers={"protectionKey": decouple.config("PROTECTED_API_KEY")}, + ) + try: + body = resp.json() + except Exception: + logger.error( + "verify_google_temp_token: HTTP %s | non-JSON body: %s", + resp.status_code, resp.text[:500], + ) + raise CustomException( + "Auth service returned an unexpected error. Please try again." + ) + if not resp.ok or body.get("statusCode") != 200: + logger.error( + "verify_google_temp_token failed: HTTP %s | body: %s", + resp.status_code, resp.text[:500], + ) + raise CustomException( + body.get("message") + or "Invalid or expired Google session. Please sign in with Google again." + ) + data = body.get("response") or {} + if not data.get("email"): + raise CustomException("Google verification returned no email.") + return data + + +def get_auth_token_by_id(user_id): + """ + Gets JWT tokens for a user by their UUID using the internal + TokenVerificationAPI endpoint. + Used for Google sign-ups where no password is set (NULL). + """ + resp = _post_auth( + f"/api/v1/auth/token-verification/{user_id}/", + headers={"protectionKey": decouple.config("PROTECTED_API_KEY")}, + ) + try: + body = resp.json() + except Exception: + logger.error( + "get_auth_token_by_id: HTTP %s | non-JSON body: %s", + resp.status_code, resp.text[:500], + ) + raise CustomException( + "Auth service returned an unexpected error. Please try again." + ) + if not resp.ok or body.get("statusCode") != 200: + logger.error( + "get_auth_token_by_id failed: HTTP %s | body: %s", + resp.status_code, resp.text[:500], + ) + raise CustomException( + body.get("message") + or "Auth service returned an unexpected error. Please try again." + ) + return body.get("response") \ No newline at end of file diff --git a/api/register/register_views.py b/api/register/register_views.py index 3c5497564..52a756e0b 100644 --- a/api/register/register_views.py +++ b/api/register/register_views.py @@ -8,7 +8,7 @@ from utils.response import CustomResponse from utils.types import OrganizationType from . import serializers -from .register_helper import get_auth_token +from .register_helper import get_auth_token, verify_google_temp_token, get_auth_token_by_id from django.views.decorators.cache import cache_page from django.core.cache import cache from mu_celery.task import send_email @@ -16,6 +16,8 @@ from decouple import config import requests from mu_celery.task import onboard_user +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse +from rest_framework import serializers as s DISCORD_CLIENT_ID = config("DISCORD_CLIENT_ID") DISCORD_CLIENT_SECRET = config("DISCORD_CLIENT_SECRET") @@ -23,6 +25,9 @@ class ConnectDiscordAPI(APIView): + @extend_schema(tags=['Register'], description="Retrieve Connect Discord.", + responses={200: OpenApiResponse(description="Discord onboard success message")}, + ) def get(self, request): if not JWTUtils.is_jwt_authenticated(request): return CustomResponse( @@ -63,6 +68,9 @@ def get(self, request): class UserDomainSelectionAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema(tags=['Register'], description="Create User Domain Selection.", + responses={200: serializers.UserSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) domains = request.data.get("domains") @@ -88,6 +96,9 @@ def post(self, request): class UserEndgoalSelectionAPI(APIView): permission_classes = [CustomizePermission] + @extend_schema(tags=['Register'], description="Create User Endgoal Selection.", + responses={200: serializers.UserSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) endgoals = request.data.get("endgoals") @@ -168,6 +179,12 @@ def post(self, request): class UnverifiedOrganizationCreateView(APIView): permission_classes = [CustomizePermission] + @extend_schema( + tags=['Register'], + description="Create Unverified Organization Create.", + request=serializers.UnverifiedOrganizationCreateSerializer, + responses={200: serializers.UnverifiedOrganizationCreateSerializer}, + ) def post(self, request): user_id = JWTUtils.fetch_user_id(request) serialized_org = serializers.UnverifiedOrganizationCreateSerializer( @@ -186,6 +203,12 @@ def post(self, request): class UserRegisterValidateAPI(APIView): + @extend_schema( + tags=['Register'], + description="Update User Register Validate.", + request=serializers.RegisterSerializer, + responses={200: serializers.RegisterSerializer}, + ) def put(self, request): serialized_user = serializers.RegisterSerializer(data=request.data) @@ -199,6 +222,9 @@ def put(self, request): class RoleAPI(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema(tags=['Register'], description="Retrieve Role.", + responses={200: serializers.RoleSerializer}, + ) def get(self, request): roles = Role.objects.all().values("id", "title") return CustomResponse(response={"roles": roles}).get_success_response() @@ -206,6 +232,15 @@ def get(self, request): class CollegesAPI(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema(tags=['Register'], description="Retrieve Colleges.", + responses={200: inline_serializer( + name='RegisterCollegesResponse', + fields={'colleges': s.ListField(child=inline_serializer( + name='RegisterCollegeItem', + fields={'id': s.CharField(), 'title': s.CharField()}, + ))}, + )}, + ) def get(self, request): colleges = Organization.objects.filter( org_type=OrganizationType.COLLEGE.value @@ -216,6 +251,9 @@ def get(self, request): class DepartmentAPI(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema(tags=['Register'], description="Retrieve Department.", + responses={200: serializers.DepartmentSerializer}, + ) def get(self, request): department_serializer = Department.objects.all().values("id", "title") @@ -230,6 +268,15 @@ def get(self, request): class CompanyAPI(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema(tags=['Register'], description="Retrieve Company.", + responses={200: inline_serializer( + name='RegisterCompaniesResponse', + fields={'companies': s.ListField(child=inline_serializer( + name='RegisterCompanyItem', + fields={'id': s.CharField(), 'title': s.CharField()}, + ))}, + )}, + ) def get(self, request): company_queryset = Organization.objects.filter( org_type=OrganizationType.COMPANY.value @@ -245,6 +292,11 @@ def get(self, request): class LearningCircleUserViewAPI(APIView): + @extend_schema( + tags=['Register'], + description="Create Learning Circle User View.", + responses={200: serializers.LearningCircleUserSerializer}, + ) def post(self, request): muid = request.headers.get("muid") @@ -271,21 +323,99 @@ def post(self, request): class RegisterDataAPI(APIView): + @extend_schema( + tags=['Register'], + description="Create Register Data.", + request=serializers.RegisterSerializer, + responses={200: serializers.UserDetailSerializer}, + ) def post(self, request): - data = request.data + from utils.exception import CustomException + + data = request.data.copy() data = {key: value for key, value in data.items() if value} + is_google_signup = False + temp_token = data.pop("tempToken", None) + + if temp_token: + # ── Google sign-up path ────────────────────────────────────────── + try: + google_data = verify_google_temp_token(temp_token) + except CustomException as e: + return CustomResponse(general_message=str(e)).get_failure_response() + + user_data = dict(data.get("user", {})) + + # Only email is forced from Google — client cannot spoof it (B5 / D1) + user_data["email"] = google_data["email"].strip().lower() + + # full_name comes from the form (editable per D1). + # Fall back to the Google value if the client didn't send one. + if not user_data.get("full_name"): + user_data["full_name"] = google_data["fullName"] + + user_data.pop("password", None) # no password for Google users + data["user"] = user_data + is_google_signup = True + + # B3: idempotent re-issue — if this email already has a NULL-password + # user it is an orphan from a prior failed attempt; just re-issue tokens. + # Uses User.every (not User.objects) because the default ActiveUserManager + # excludes suspended accounts; their emails still occupy the unique constraint + # and would cause a DB-level IntegrityError on save(). + existing = User.every.filter(email=user_data["email"]).first() + if existing: + if existing.password: + # Has a real password → was registered via the classic path + return CustomResponse( + general_message="An account with this email already exists. Please sign in normally." + ).get_failure_response() + if existing.suspended_at: + # Suspended Google user — do not re-issue tokens + return CustomResponse( + general_message="This account has been suspended." + ).get_failure_response() + # Orphan NULL-password user → re-issue tokens without creating a new row + try: + res_data = get_auth_token_by_id(existing.id) + except CustomException as e: + return CustomResponse(general_message=str(e)).get_failure_response() + res_data["data"] = serializers.UserDetailSerializer(existing).data + return CustomResponse(response=res_data).get_success_response() + # ── End Google sign-up path ────────────────────────────────────── + create_user = serializers.RegisterSerializer( data=data, context={"request": request} ) if not create_user.is_valid(): return CustomResponse(message=create_user.errors).get_failure_response() - user = create_user.save() - cache.set(f"db_user_{user.muid}", user, timeout=60) - password = request.data["user"]["password"] - cache.set(f"flag_register_{user.muid}", True, timeout=5) - res_data = get_auth_token(user.muid, password) + if not is_google_signup: + password = request.data.get("user", {}).get("password") + if not password: + return CustomResponse( + general_message="Password is required for email registration." + ).get_failure_response() + + try: + # Save user first — commits to DB so the auth service can find + # the user when we request tokens (avoids uncommitted-row problem). + user = create_user.save() + cache.set(f"db_user_{user.muid}", user, timeout=60) + + try: + if is_google_signup: + # NULL password — use TokenVerificationAPI (no password needed) + res_data = get_auth_token_by_id(user.id) + else: + res_data = get_auth_token(user.muid, password) + except CustomException: + # Auth service failed — clean up the orphaned user + user.delete() + raise + except CustomException as e: + return CustomResponse(general_message=str(e)).get_failure_response() response_data = serializers.UserDetailSerializer(user, many=False).data @@ -296,12 +426,16 @@ def post(self, request): ) res_data["data"] = response_data - return CustomResponse(response=res_data).get_success_response() class CountryAPI(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema( + tags=['Register'], + description="Retrieve Country.", + responses={200: serializers.CountrySerializer}, + ) def get(self, request): countries = Country.objects.all() @@ -315,6 +449,11 @@ def get(self, request): class StateAPI(APIView): + @extend_schema( + tags=['Register'], + description="Create State.", + responses={200: serializers.StateSerializer}, + ) def post(self, request): state = State.objects.filter(country_id=request.data.get("country")) serializer = serializers.StateSerializer(state, many=True) @@ -327,6 +466,11 @@ def post(self, request): class DistrictAPI(APIView): + @extend_schema( + tags=['Register'], + description="Create District.", + responses={200: serializers.DistrictSerializer}, + ) def post(self, request): district = District.objects.filter(zone__state_id=request.data.get("state")) @@ -340,11 +484,33 @@ def post(self, request): class CollegeAPI(APIView): + MAX_RESULTS = 20 + + @extend_schema( + tags=['Register'], + description="Create College.", + responses={200: serializers.OrgSerializer}, + ) def post(self, request): + district_id = request.data.get("district") + search_query = request.data.get("search", "").strip() + + # Build base query for colleges in the specified district org_queryset = Organization.objects.filter( - Q(org_type=OrganizationType.COLLEGE.value), - Q(district_id=request.data.get("district")), + org_type=OrganizationType.COLLEGE.value, ) + + # Filter by district if provided + if district_id: + org_queryset = org_queryset.filter(district_id=district_id) + + # Apply search filter if search query is provided + if search_query: + org_queryset = org_queryset.filter(title__icontains=search_query) + + # Limit results to prevent memory issues + org_queryset = org_queryset[:self.MAX_RESULTS] + department_queryset = Department.objects.all() college_serializer_data = serializers.OrgSerializer( @@ -364,6 +530,11 @@ def post(self, request): class SchoolAPI(APIView): + @extend_schema( + tags=['Register'], + description="Create School.", + responses={200: serializers.OrgSerializer}, + ) def post(self, request): org_queryset = Organization.objects.filter( Q(org_type=OrganizationType.SCHOOL.value), @@ -383,6 +554,11 @@ def post(self, request): class CommunityAPI(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema( + tags=['Register'], + description="Retrieve Community.", + responses={200: serializers.OrgSerializer}, + ) def get(self, request): community_queryset = Organization.objects.filter( org_type=OrganizationType.COMMUNITY.value @@ -399,6 +575,11 @@ def get(self, request): class AreaOfInterestAPI(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema( + tags=['Register'], + description="Retrieve Area Of Interest.", + responses={200: serializers.AreaOfInterestAPISerializer}, + ) def get(self, request): aoi_queryset = InterestGroup.objects.all() @@ -412,6 +593,9 @@ def get(self, request): class UserEmailVerificationAPI(APIView): + @extend_schema(tags=['Register'], description="Create User Email Verification.", + responses={200: serializers.UserSerializer}, + ) def post(self, request): user_email = request.data.get("email") @@ -427,6 +611,11 @@ def post(self, request): class UserCountryAPI(APIView): @method_decorator(cache_page(60 * 10)) + @extend_schema( + tags=['Register'], + description="Retrieve User Country.", + responses={200: serializers.UserCountrySerializer}, + ) def get(self, request): country = Country.objects.all() @@ -441,6 +630,11 @@ def get(self, request): class UserStateAPI(APIView): + @extend_schema( + tags=['Register'], + description="Retrieve User State.", + responses={200: serializers.UserStateSerializer}, + ) def get(self, request): country_name = request.data.get("country") @@ -464,6 +658,11 @@ def get(self, request): class UserZoneAPI(APIView): + @extend_schema( + tags=['Register'], + description="Retrieve User Zone.", + responses={200: serializers.UserZoneSerializer}, + ) def get(self, request): state_name = request.data.get("state") @@ -487,6 +686,11 @@ def get(self, request): class LocationSearchView(APIView): + @extend_schema( + tags=['Register'], + description="Retrieve Location Search.", + responses={200: serializers.LocationSerializer}, + ) def get(self, request): query = request.GET.get("q") MAX_RESULTS = 7 diff --git a/api/register/serializers.py b/api/register/serializers.py index 88ff32acb..f52448410 100644 --- a/api/register/serializers.py +++ b/api/register/serializers.py @@ -34,7 +34,7 @@ ) from utils.exception import CustomException from utils.types import OrganizationType, RoleType -from utils.utils import DateTimeUtils +from utils.utils import DateTimeUtils, check_alumni_status from . import register_helper @@ -137,6 +137,7 @@ def create(self, validated_data): verified=True, department=department if is_college(org) else None, graduation_year=graduation_year if is_college(org) else None, + is_alumni=check_alumni_status(graduation_year) if is_college(org) else False, ) for org in validated_data["organizations"] } @@ -308,9 +309,8 @@ def create(self, validated_data): validated_data["full_name"] ) - password = validated_data.pop("password") - hashed_password = make_password(password) - validated_data["password"] = hashed_password + password = validated_data.pop("password", None) + validated_data["password"] = make_password(password) if password else None user = super().create(validated_data) @@ -323,12 +323,18 @@ def create(self, validated_data): if level := Level.objects.filter(level_order="1").first(): UserLvlLink.objects.create(level=level, **additional_values) - if role: - additional_values.pop("updated_by") + additional_values.pop("updated_by") + + if not role: + role = Role.objects.filter(title=RoleType.MULEARNER.value).first() + if role: UserRoleLink.objects.create( role=role, - verified=role.title == RoleType.STUDENT.value, + verified=role.title in ( + RoleType.STUDENT.value, + RoleType.MULEARNER.value, + ), **additional_values, ) @@ -360,6 +366,10 @@ class Meta: "district", "area_of_interest", ] + extra_kwargs = { + # Google sign-ups don't supply a password — must be optional here. + "password": {"required": False, "allow_null": True}, + } # class UserInterestSerializer(serializers.ModelSerializer): diff --git a/api/templates/mails/donation_confirmation.html b/api/templates/mails/donation_confirmation.html new file mode 100644 index 000000000..8ed8173a1 --- /dev/null +++ b/api/templates/mails/donation_confirmation.html @@ -0,0 +1,80 @@ +{% extends "mails/base_mail.html" %} {% block content %} +
+
+

+ THANK YOU FOR YOUR DONATION! +

+

+ Your generous contribution helps us continue our mission of empowering learners across the country. +

+ + +
+ +
+ 💝 +
+ +

+ We have attached your donation receipt to this email. Please keep it for your records. +

+
+ +
+ +

+ If you require an 80G certificate for tax exemption purposes, please reply to this email. +

+ +
+ +

+ Together, we're building a stronger learning community. Thank you for being a part of this journey! +

+
+
+{% endblock %} \ No newline at end of file diff --git a/api/top100_coders/top100_view.py b/api/top100_coders/top100_view.py index 73ca4036d..a7903eb57 100644 --- a/api/top100_coders/top100_view.py +++ b/api/top100_coders/top100_view.py @@ -2,9 +2,27 @@ from rest_framework.views import APIView from utils.response import CustomResponse +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s class Leaderboard(APIView): + @extend_schema(tags=['Top100 Coders'], description="Retrieve Leaderboard.", + responses={200: inline_serializer( + name='Top100LeaderboardItem', + fields={ + 'id': s.CharField(), + 'full_name': s.CharField(), + 'profile_pic': s.CharField(allow_null=True), + 'total_karma': s.IntegerField(allow_null=True), + 'org': s.CharField(allow_null=True), + 'dis': s.CharField(allow_null=True), + 'state': s.CharField(allow_null=True), + 'time_': s.DateTimeField(allow_null=True), + }, + many=True, + )}, + ) def get(self, request): query = """ SELECT diff --git a/api/url_shortener/url_shortener_view.py b/api/url_shortener/url_shortener_view.py index a57c4001a..71e019f9c 100644 --- a/api/url_shortener/url_shortener_view.py +++ b/api/url_shortener/url_shortener_view.py @@ -10,6 +10,8 @@ from utils.types import RoleType from utils.response import CustomResponse from utils.utils import CommonUtils +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as s class UrlShortenerAPI(APIView): @@ -18,6 +20,12 @@ class UrlShortenerAPI(APIView): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value] ) + @extend_schema( + tags=['Url Shortener'], + description="Create Url Shortener.", + request=ShortenUrlsCreateUpdateSerializer, + responses={200: ShortenUrlsCreateUpdateSerializer}, + ) def post(self, request): serializer = ShortenUrlsCreateUpdateSerializer( @@ -41,6 +49,11 @@ def post(self, request): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value] ) + @extend_schema( + tags=['Url Shortener'], + description="Retrieve Url Shortener.", + responses={200: ShowShortenUrlsSerializer}, + ) def get(self, request): url_shortener_objects = UrlShortener.objects.all().order_by('-created_at') @@ -78,6 +91,11 @@ def get(self, request): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value] ) + @extend_schema( + tags=['Url Shortener'], + description="Update Url Shortener.", + responses={200: ShortenUrlsCreateUpdateSerializer}, + ) def put(self, request, url_id): url_shortener = UrlShortener.objects.filter( id=url_id @@ -109,6 +127,9 @@ def put(self, request, url_id): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value] ) + @extend_schema(tags=['Url Shortener'], description="Delete Url Shortener.", + responses={200: ShortenUrlsCreateUpdateSerializer}, + ) def delete(self, request, url_id): url_shortener_object = UrlShortener.objects.filter( id=url_id @@ -131,16 +152,60 @@ class UrlAnalyticsAPI(APIView): @role_required( [RoleType.ADMIN.value, RoleType.FELLOW.value, RoleType.ASSOCIATE.value] ) + @extend_schema(tags=['Url Shortener'], description="Retrieve Url Analytics.", + responses={200: inline_serializer( + name='UrlShortenerAnalyticsResponse', + fields={ + 'total_clicks': s.IntegerField(), + 'created_on': s.CharField(), + 'browsers': s.JSONField(), + 'platforms': s.JSONField(), + 'devices': s.JSONField(), + 'sources': s.JSONField(), + 'ip_address': s.JSONField(), + 'city': s.JSONField(), + 'region': s.JSONField(), + 'countries': s.JSONField(), + 'time_based_data': s.JSONField(), + 'long_url': s.CharField(), + 'short_url': s.CharField(), + 'title': s.CharField(), + }, + )}, + ) def get(self, request, url_id): queryset = UrlShortenerTracker.objects.filter(url_shortener__id=url_id) # long and short url url_shortener = UrlShortener.objects.filter(id=url_id).first() - if not queryset.exists(): - # Return an appropriate response for the case where no records are found + if not url_shortener: + # The short URL itself does not exist — this is a genuine 404. return CustomResponse( - general_message="No records found" - ).get_failure_response() + general_message="Short URL not found" + ).get_failure_response(status_code=404) + + if not queryset.exists(): + # The URL exists but has not been clicked yet. This is a valid empty + # state, not an error, so return a 200 with zero-value analytics. + # The client renders zeros instead of a failure screen. + empty_result = { + 'total_clicks': 0, + 'created_on': url_shortener.created_at.strftime('%Y-%m-%d') + if getattr(url_shortener, 'created_at', None) else None, + 'browsers': {}, + 'platforms': {}, + 'devices': {}, + 'sources': {}, + 'ip_address': {}, + 'city': {}, + 'region': {}, + 'countries': {}, + 'time_based_data': {'all_time': []}, + 'long_url': url_shortener.long_url, + 'short_url': url_shortener.short_url, + 'title': url_shortener.title, + } + return CustomResponse(response=empty_result).get_success_response() browsers = {} platforms = {} diff --git a/api/urls.py b/api/urls.py index 5ceaef584..fb11bcfd8 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,8 +1,9 @@ import debug_toolbar from django.urls import path, include -# app_name will help us do a reverse look-up latter. +# app_name will help us do a reverse look-up latter. urlpatterns = [ + path('auth/', include('api.auth.urls')), path('register/', include('api.register.urls')), path('leaderboard/', include('api.leaderboard.urls')), path('dashboard/', include('api.dashboard.urls')), @@ -15,5 +16,7 @@ path('top100/', include('api.top100_coders.urls')), path('launchpad/', include('api.launchpad.urls')), path('donate/', include('api.donate.urls')), + path('calendar/', include('api.calendar.urls')), + path('muComics/', include('api.muComics.urls')), path("__debug__/", include(debug_toolbar.urls)), ] diff --git a/api_docs/.md b/api_docs/.md new file mode 100644 index 000000000..a80e73964 --- /dev/null +++ b/api_docs/.md @@ -0,0 +1,523 @@ +# API + +Base path: `/api/` + + + +## Endpoint: `auth/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `register/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `leaderboard/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `dashboard/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `integrations/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `url-shortener/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `protected/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `hackathon/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `notification/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `public/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `top100/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `launchpad/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `donate/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +## Endpoint: `__debug__/` + +- Brief: List or collection endpoint. Returns paginated results. + +- Request body example (JSON): +```json +{ + "name": "example_name" +} +``` + +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "data": [ + { + "id": "9f4b6c2e-...", + "name": "example_name" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` diff --git a/api_docs/API_REFERENCE.md b/api_docs/API_REFERENCE.md new file mode 100644 index 000000000..04f7669e2 --- /dev/null +++ b/api_docs/API_REFERENCE.md @@ -0,0 +1,58 @@ +# API Reference + +This index lists the API documentation available in this repository. Open any linked file for endpoint details and examples. + +## Core/Public APIs + +- Auth: [Auth.md](api_docs/Auth.md) +- Common (public): [Common.md](api_docs/Common.md) +- Donate: [Donate.md](api_docs/Donate.md) +- Hackathon: [Hackathon.md](api_docs/Hackathon.md) +- Integrations: [Integrations.md](api_docs/Integrations.md) +- Launchpad: [Launchpad.md](api_docs/Launchpad.md) +- Leaderboard: [Leaderboard.md](api_docs/Leaderboard.md) +- Notification: [Notification.md](api_docs/Notification.md) +- Protected: [Protected.md](api_docs/Protected.md) +- Register: [Register.md](api_docs/Register.md) +- Top100Coders: [Top100Coders.md](api_docs/Top100Coders.md) +- UrlShortener: [UrlShortener.md](api_docs/UrlShortener.md) + +## Dashboard + +- Location: [Dashboard_Location.md](api_docs/Dashboard_Location.md) +- Management: [Dashboard_Management.md](api_docs/Dashboard_Management.md) +- Task: [Dashboard_Task.md](api_docs/Dashboard_Task.md) +- User: [Dashboard_User.md](api_docs/Dashboard_User.md) +- Utilities: [Dashboard_Utilities.md](api_docs/Dashboard_Utilities.md) + +## Dashboard + +- Achievement: [Dashboard_Achievement.md](api_docs/Dashboard_Achievement.md) +- Affiliation: [Dashboard_Affiliation.md](api_docs/Dashboard_Affiliation.md) +- Campus: [Dashboard_Campus.md](api_docs/Dashboard_Campus.md) +- Channels: [Dashboard_Channels.md](api_docs/Dashboard_Channels.md) +- College: [Dashboard_College.md](api_docs/Dashboard_College.md) +- Coupon: [Dashboard_Coupon.md](api_docs/Dashboard_Coupon.md) +- Discord Moderator: [Dashboard_Discord_Moderator.md](api_docs/Dashboard_Discord_Moderator.md) +- District: [Dashboard_District.md](api_docs/Dashboard_District.md) +- Events: [Dashboard_Events.md](api_docs/Dashboard_Events.md) +- IG: [Dashboard_IG.md](api_docs/Dashboard_IG.md) +- Karma Voucher: [Dashboard_Karma_Voucher.md](api_docs/Dashboard_Karma_Voucher.md) +- LC: [Dashboard_LC.md](api_docs/Dashboard_LC.md) +- LearningCircle: [Dashboard_LearningCircle.md](api_docs/Dashboard_LearningCircle.md) +- Organisation: [Dashboard_Organisation.md](api_docs/Dashboard_Organisation.md) +- Profile: [Dashboard_Profile.md](api_docs/Dashboard_Profile.md) +- Projects: [Dashboard_Projects.md](api_docs/Dashboard_Projects.md) +- Referral: [Dashboard_Referral.md](api_docs/Dashboard_Referral.md) +- Roles: [Dashboard_Roles.md](api_docs/Dashboard_Roles.md) +- Task Report: [Dashboard_Task_Report.md](api_docs/Dashboard_Task_Report.md) +- Zonal: [Dashboard_Zonal.md](api_docs/Dashboard_Zonal.md) + +--- + +If you'd like, I can expand the autogenerated files with method signatures, request/response samples, and example calls. Tell me which modules to prioritize. +- Roles: [Dashboard_Roles.md](api_docs/Dashboard_Roles.md) +- Task Report: [Dashboard_Task_Report.md](api_docs/Dashboard_Task_Report.md) +- Zonal: [Dashboard_Zonal.md](api_docs/Dashboard_Zonal.md) + +If you'd like, I can expand each autogenerated file with method types, request/response samples, and examples. diff --git a/api_docs/Auth.md b/api_docs/Auth.md new file mode 100644 index 000000000..db2240dc5 --- /dev/null +++ b/api_docs/Auth.md @@ -0,0 +1,83 @@ +# Auth + + +Base path: `/api/auth/` + + +## Endpoint: `google-mobile/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `apple-mobile/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `refresh-token/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Common.md b/api_docs/Common.md new file mode 100644 index 000000000..4b012e24e --- /dev/null +++ b/api_docs/Common.md @@ -0,0 +1,531 @@ +# Common + + +Base path: `/api/common/` + + +## Endpoint: `lc-list` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/lc-details/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `lc-dashboard/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `lc-report/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `college-wise-lc-report/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `college-wise-lc-report/csv/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `lc-report/csv/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `lc-enrollment/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `lc-enrollment/csv/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `global-count/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `gta-sandshore/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `profile-pic//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-ig/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-ig-top100/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/levels/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `leaderboard/top-100/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/college/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/district/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/state/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/country/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard.md b/api_docs/Dashboard.md new file mode 100644 index 000000000..ae4067408 --- /dev/null +++ b/api_docs/Dashboard.md @@ -0,0 +1,655 @@ +# Dashboard + + +Base path: `/api/dashboard/` + + +## Endpoint: `user/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `zonal/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `district/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `campus/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `roles/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `ig/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `task/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `profile/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `lc/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `learningcircle/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `referral/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `college/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `karma-voucher/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `location/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `organisation/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dynamic-management/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `error-log/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `affiliation/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `channels/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `discord-moderator/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `events/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `coupon/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `projects/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `achievement/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `task-report/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Achievement.md b/api_docs/Dashboard_Achievement.md new file mode 100644 index 000000000..2919b44c5 --- /dev/null +++ b/api_docs/Dashboard_Achievement.md @@ -0,0 +1,204 @@ +# Dashboard / Achievement + + +Base path: `/api/dashboard/achievement/` + + +## Endpoint: `list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `update//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:achievement_id` +- Request body example (JSON): +```json +{ + "achievement_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:achievement_id` +- Request body example (JSON): +```json +{ + "achievement_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/user//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `issue-vc/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + +``` + + +## Endpoint: `bulk-claim/` +- Brief: Bulk sync endpoint to check and issue all eligible achievements for users active within a date range. Requires API Key authentication. +- Method: `POST` +- Headers: + - `Api-Key: ` + - `Content-Type: application/json` +- Request body example (JSON): +```json +{ + "date_from": "2026-01-14", + "date_to": "2026-01-15" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Bulk sync job scheduled successfully." + ] + }, + "response": {} +} +``` +- Notes: + - Both `date_from` and `date_to` are optional and default to yesterday's date. + - The task runs asynchronously via Celery. + - Logs are written to `logs/root.log`. + diff --git a/api_docs/Dashboard_Affiliation.md b/api_docs/Dashboard_Affiliation.md new file mode 100644 index 000000000..0a5d15062 --- /dev/null +++ b/api_docs/Dashboard_Affiliation.md @@ -0,0 +1,34 @@ +# Dashboard / Affiliation + + +Base path: `/api/dashboard/affiliation/` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:affiliation_id` +- Request body example (JSON): +```json +{ + "affiliation_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Campus.md b/api_docs/Dashboard_Campus.md new file mode 100644 index 000000000..d77878433 --- /dev/null +++ b/api_docs/Dashboard_Campus.md @@ -0,0 +1,1007 @@ +# Dashboard — Campus API + +**Base path:** `/api/dashboard/campus/` +**Authentication:** JWT Bearer Token required on all endpoints. + +--- + +## Table of Contents + +| # | Endpoint | Method(s) | Auth / Role | +|---|----------|-----------|-------------| +| 1 | [`campus-details/`](#1-campus-details) | `GET` | Campus Lead, Lead Enabler | +| 2 | [`/`](#2-org_id--public-campus-details) | `GET` | Any authenticated user | +| 3 | [`student-level/`](#3-student-level) | `GET` | Any authenticated user | +| 4 | [`student-level//`](#4-student-levelorg_id) | `GET` | Any authenticated user | +| 5 | [`student-details/`](#5-student-details) | `GET` | Campus Lead, Lead Enabler | +| 6 | [`student-details/csv/`](#6-student-detailscsv) | `GET` | Campus Lead, Lead Enabler | +| 7 | [`weekly-karma/`](#7-weekly-karma) | `GET` | Any authenticated user | +| 8 | [`weekly-karma//`](#8-weekly-karmaorg_id) | `GET` | Any authenticated user | +| 9 | [`change-student-type//`](#9-change-student-typemember_id) | `PATCH` | Campus Lead, Lead Enabler | +| 10 | [`transfer-lead-role/`](#10-transfer-lead-role) | `POST` | Campus Lead | +| 11 | [`transfer-enabler-role/`](#11-transfer-enabler-role) | `POST` | Campus Lead | +| 12 | [`transfer-ig-role/`](#12-transfer-ig-role) | `GET`, `POST` | Campus Lead, Lead Enabler | +| 13 | [`events/`](#13-events) | `GET` | Campus Lead, Lead Enabler | +| 14 | [`events/distribution/`](#14-eventsdistribution) | `GET` | Campus Lead, Lead Enabler | +| 15 | [`execom/`](#15-execom) | `GET`, `POST` | Campus Lead (POST), Campus Lead / Lead Enabler (GET) | +| 16 | [`execom//`](#16-execommember_id) | `DELETE` | Campus Lead | +| 17 | [`ig-chapters/`](#17-ig-chapters) | `GET`, `POST` | Campus Lead, Lead Enabler | +| 18 | [`ig-chapters//`](#18-ig-chapterschapter_id) | `PATCH`, `DELETE` | Campus Lead, Lead Enabler | +| 19 | [`social-links/`](#19-social-links) | `PUT` | Campus Lead, Lead Enabler | +| 20 | [`social-links//`](#20-social-linkslink_id) | `DELETE` | Campus Lead, Lead Enabler | +| 21 | [`/leaderboard/`](#21-org_idleaderboard) | `GET` | Any authenticated user | +| 22 | [`/karma-by-cluster/`](#22-org_idkarma-by-cluster) | `GET` | Any authenticated user | +| 23 | [`campus-list/`](#23-campus-list) | `GET` | Any authenticated user | + +**Campus mentor & sessions:** see [Dashboard_Campus_Mentor_Sessions.md](./Dashboard_Campus_Mentor_Sessions.md) (`assign-mentor/`, `sessions/create/`, `sessions/list/`). + +--- + +## 1. Campus Details + +**`GET /api/dashboard/campus/campus-details/`** + +Returns detailed information about the authenticated Campus Lead's own campus, including lead names, karma stats, and active IG count. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Request Body:** None + +**Query Params:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "org_id": "82e6f245-59ea-4be4-a33d-35189a395000", + "college_name": "Example College of Engineering", + "campus_code": "ECE001", + "campus_zone": "South Zone", + "campus_level": 3, + "total_karma": 45200, + "total_members": 312, + "active_members": 180, + "rank": 5, + "lead": { + "campus_lead": "Arjun Menon", + "enabler": "Priya Nair" + }, + "karma_last_7_days": 1200, + "karma_last_30_days": 4800, + "active_ig_count": 7, + "social_links": [ + { + "id": "e5f67890-abcd-ef12-3456-7890abcdef12", + "platform": "instagram", + "url": "https://instagram.com/eec_mulearn" + } + ] + } +} +``` + +--- + +## 2. `/` — Public Campus Details + +**`GET /api/dashboard/campus//`** + +Returns public details of any campus by its organisation ID. Does not require a specific role. + +**Path Params:** +- `org_id` *(string)* — UUID of the college organisation + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "org_id": "82e6f245-59ea-4be4-a33d-35189a395000", + "college_name": "Example College of Engineering", + "campus_code": "ECE001", + "campus_zone": "South Zone", + "campus_level": 3, + "total_karma": 45200, + "total_members": 312, + "active_members": 180, + "rank": 5, + "social_links": [ + { + "id": "e5f67890-abcd-ef12-3456-7890abcdef12", + "platform": "instagram", + "url": "https://instagram.com/eec_mulearn" + } + ] + } +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["College not found"] } } +``` + +--- + +## 3. Student Level + +**`GET /api/dashboard/campus/student-level/`** + +Returns the number of students at each karma level for the authenticated user's campus. + +**Roles:** Any authenticated user (no role restriction) + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": [ + { "level": 1, "students": 120 }, + { "level": 2, "students": 80 }, + { "level": 3, "students": 45 }, + { "level": 4, "students": 20 }, + { "level": 5, "students": 5 } + ] +} +``` + +--- + +## 4. `student-level//` + +**`GET /api/dashboard/campus/student-level//`** + +Same as above but for any specific college by `org_id`. + +**Path Params:** +- `org_id` *(string)* — UUID of the college organisation + +**Request Body:** None + +**Response:** Same structure as [Student Level](#3-student-level). + +--- + +## 5. Student Details + +**`GET /api/dashboard/campus/student-details/`** + +Returns a paginated list of students in the campus lead's college with their karma, level, rank, department, and alumni status. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Query Params:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `is_alumni` | `"true"` / `"false"` | No | Filter by alumni status | +| `pageIndex` | integer | No | Page number (default: 1) | +| `perPage` | integer | No | Items per page (default: 20) | +| `sortBy` | string | No | Field to sort by (`full_name`, `karma`, `level`, `join_date`, `muid`, `email`, `mobile`, `is_alumni`) | +| `search` | string | No | Search by name or level | + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "user_id": "a1b2c3d4-...", + "full_name": "Rahul Krishna", + "muid": "rahul-krishna@mulearn", + "email": "rahul@example.com", + "mobile": "9876543210", + "karma": 3400, + "rank": 1, + "level": "Pathfinder", + "join_date": "2023-08-01T10:00:00Z", + "last_karma_gained": "2024-03-10T14:30:00Z", + "department": "Computer Science", + "graduation_year": "2025", + "is_alumni": false + } + ], + "pagination": { + "count": 312, + "totalPages": 16, + "isFirst": true, + "isLast": false, + "nextPage": "/student-details/?pageIndex=2" + } + } +} +``` + +--- + +## 6. Student Details CSV + +**`GET /api/dashboard/campus/student-details/csv/`** + +Downloads the same student data as endpoint 5 as a CSV file. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Query Params:** Same as [Student Details](#5-student-details) + +**Request Body:** None + +**Response:** `text/csv` file download — `Campus Student Details.csv` + +--- + +## 7. Weekly Karma + +**`GET /api/dashboard/campus/weekly-karma/`** + +Returns the total karma earned by students in the campus lead's college for each of the last 7 days. + +**Roles:** Any authenticated user + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "college_name": "Example College of Engineering", + "2024-03-15": 520, + "2024-03-14": 340, + "2024-03-13": 410, + "2024-03-12": 290, + "2024-03-11": 600, + "2024-03-10": 480, + "2024-03-09": 310 + } +} +``` + +--- + +## 8. `weekly-karma//` + +**`GET /api/dashboard/campus/weekly-karma//`** + +Same as above but for any specific college by `org_id`. + +**Path Params:** +- `org_id` *(string)* — UUID of the college organisation + +**Response:** Same structure as [Weekly Karma](#7-weekly-karma). + +--- + +## 9. Change Student Type + +**`PATCH /api/dashboard/campus/change-student-type//`** + +Toggles a student's alumni status within the campus lead's college. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Path Params:** +- `member_id` *(string)* — UUID of the target user + +**Request Body:** +```json +{ + "is_alumni": true +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `is_alumni` | boolean | Yes | `true` = mark as alumni, `false` = mark as student | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Student Type updated successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["User have no organization"] } } +``` + +--- + +## 10. Transfer Lead Role + +**`POST /api/dashboard/campus/transfer-lead-role/`** + +Transfers the `Campus Lead` role from the current lead to another student in the same college. The current lead **loses** the role. + +**Roles:** `Campus Lead` only + +**Request Body:** +```json +{ + "new_lead_muid": "rahul-krishna@mulearn" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `new_lead_muid` | string | Yes | muID of the new campus lead (must be a non-alumni member of the same college) | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Assigned new Campus Lead successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["Required data is missing"] } } +{ "hasError": true, "statusCode": 400, "message": { "general": ["Can't find the user"] } } +{ "hasError": true, "statusCode": 400, "message": { "general": ["Can't find the user in your college"] } } +``` + +--- + +## 11. Transfer Enabler Role + +**`POST /api/dashboard/campus/transfer-enabler-role/`** + +Assigns the `Lead Enabler` role to a student, replacing the current enabler if one exists. + +**Roles:** `Campus Lead` only + +**Request Body:** +```json +{ + "new_enabler_muid": "priya-nair@mulearn" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `new_enabler_muid` | string | Yes | muID of the new enabler (must be a non-alumni member of the same college) | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Assigned new Enabler Lead successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["Can't find the user in your college"] } } +``` + +--- + +## 12. Transfer IG Role + +### `GET /api/dashboard/campus/transfer-ig-role/` + +Returns a list of IG codes that have active members in the campus. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "ig_list": ["python", "webdev", "aiml", null] + } +} +``` + +--- + +### `POST /api/dashboard/campus/transfer-ig-role/` + +Assigns the campus IG lead role for a specific Interest Group to a student, replacing the current IG lead. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Request Body:** +```json +{ + "new_ig_muid": "arun-dev@mulearn", + "ig_code": "python" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `new_ig_muid` | string | Yes | muID of the new IG campus lead | +| `ig_code` | string | Yes | Code of the Interest Group (e.g. `"python"`, `"webdev"`) | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Assigned new Ig lead successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["Can't find the role"] } } +{ "hasError": true, "statusCode": 400, "message": { "general": ["Can't find the user in your college"] } } +``` + +--- + +## 13. Events + +**`GET /api/dashboard/campus/events/`** + +Returns a paginated list of all campus-scoped and campus-IG-scoped live events for the authenticated lead's campus. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Query Params:** + +| Param | Type | Description | +|-------|------|-------------| +| `status` | string | Filter by event status (e.g. `"live"`, `"completed"`) | +| `scope` | string | Filter by scope (`"campus"`, `"campus_ig"`) | +| `event_type` | string | Filter by organiser type | +| `date_from` | date | Events starting on or after this date (`YYYY-MM-DD`) | +| `date_to` | date | Events starting on or before this date (`YYYY-MM-DD`) | +| `pageIndex` | integer | Page number | +| `perPage` | integer | Items per page | +| `sortBy` | string | `start_datetime` or `interest_count` | + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "evt-uuid", + "title": "Python Bootcamp", + "status": "live", + "scope": "campus", + "organiser_type": "internal", + "start_datetime": "2024-04-01T09:00:00Z", + "end_datetime": "2024-04-01T17:00:00Z", + "venue_type": "offline", + "venue_city": "Kochi", + "interest_count": 45, + "cover_image": "https://...", + "tags": ["python", "bootcamp"] + } + ], + "pagination": { + "count": 12, + "totalPages": 1, + "isFirst": true, + "isLast": true + } + } +} +``` + +--- + +## 14. Events Distribution + +**`GET /api/dashboard/campus/events/distribution/`** + +Returns ranked tag distribution across all campus events (how many events have each tag). + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { "tag": "python", "event_count": 8 }, + { "tag": "hackathon", "event_count": 5 }, + { "tag": "webdev", "event_count": 3 } + ] + } +} +``` + +--- + +## 15. Execom + +### `GET /api/dashboard/campus/execom/` + +Lists all execom role holders (Campus Lead, Lead Enabler, and IG Campus Leads) in the current campus. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "user_id": "a1b2c3d4-...", + "full_name": "Arjun Menon", + "muid": "arjun-menon@mulearn", + "profile_pic": "https://be.mulearn.org/media/user/profile/a1b2c3d4.png", + "role_title": "Campus Lead", + "ig_name": null + }, + { + "user_id": "b2c3d4e5-...", + "full_name": "Arun Dev", + "muid": "arun-dev@mulearn", + "profile_pic": null, + "role_title": "python CampusLead", + "ig_name": "python" + } + ] + } +} +``` + +--- + +### `POST /api/dashboard/campus/execom/` + +Appoints a campus member to an execom role (replaces any existing holder of that role in the campus). + +**Roles:** `Campus Lead` only + +**Request Body:** +```json +{ + "muid": "arun-dev@mulearn", + "role_title": "Campus Lead" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `muid` | string | Yes | muID of the member to appoint | +| `role_title` | string | Yes | Role to assign. Allowed: `"Campus Lead"`, `"Lead Enabler"`, or any `" CampusLead"` | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Role assigned successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["Invalid role"] } } +{ "hasError": true, "statusCode": 400, "message": { "general": ["muid and role_title are required"] } } +{ "hasError": true, "statusCode": 400, "message": { "general": ["User is not a member of your campus"] } } +``` + +--- + +## 16. `execom//` + +**`DELETE /api/dashboard/campus/execom//`** + +Removes an execom role link by the `UserRoleLink` ID. A Campus Lead cannot remove their own lead role this way. + +**Roles:** `Campus Lead` only + +**Path Params:** +- `member_id` *(string)* — UUID of the `UserRoleLink` record (not the user ID) + +**Request Body:** None + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Role removed successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["Role link not found or not part of this campus"] } } +{ "hasError": true, "statusCode": 400, "message": { "general": ["Cannot remove your own Campus Lead role. Use transfer-lead-role instead."] } } +``` + +--- + +## 17. IG Chapters + +### `GET /api/dashboard/campus/ig-chapters/` + +Returns all active IG chapters registered for the authenticated user's campus, including member count and lead info. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": [ + { + "id": "chapter-uuid", + "ig_id": "ig-uuid", + "ig_name": "Python", + "ig_code": "python", + "ig_icon": "🐍", + "lead_id": "user-uuid", + "lead_name": "Arun Dev", + "description": "Our campus Python chapter", + "is_active": true, + "campus_ig_member_count": 24 + } + ] +} +``` + +--- + +### `POST /api/dashboard/campus/ig-chapters/` + +Creates a new IG chapter for the campus. Only one active chapter per IG is allowed per campus. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Request Body:** +```json +{ + "ig": "ig-uuid", + "description": "Our campus Python chapter", + "lead": "user-uuid" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `ig` | string (UUID) | Yes | UUID of the Interest Group | +| `description` | string | No | Short description of the chapter | +| `lead` | string (UUID) | No | UUID of the user to appoint as IG chapter lead (must be a non-alumni campus member) | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["IG Chapter created successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "ig": ["An active IG chapter already exists for this campus and IG."] } } +{ "hasError": true, "statusCode": 400, "message": { "lead": ["The lead must be a member of this campus."] } } +``` + +--- + +## 18. `ig-chapters//` + +### `PATCH /api/dashboard/campus/ig-chapters//` + +Partially updates an existing IG chapter (description, lead, or active status). + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Path Params:** +- `chapter_id` *(string)* — UUID of the IG chapter + +**Request Body** *(all fields optional):* +```json +{ + "description": "Updated description", + "lead": "new-user-uuid", + "is_active": true +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `description` | string | No | Updated chapter description | +| `lead` | string (UUID) | No | UUID of the new lead (must be a campus member) | +| `is_active` | boolean | No | Activate or deactivate the chapter | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["IG Chapter updated successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["IG Chapter not found"] } } +``` + +--- + +### `DELETE /api/dashboard/campus/ig-chapters//` + +Soft-deletes an IG chapter (sets `is_active = false`). Also removes the IG campus lead role from the chapter lead. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Path Params:** +- `chapter_id` *(string)* — UUID of the IG chapter + +**Request Body:** None + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["IG Chapter deleted successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["IG Chapter not found"] } } +``` + +--- + +## 19. Social Links + +### `PUT /api/dashboard/campus/social-links/` + +Creates or updates (upsert) a social media link for the campus. Only one link per platform is stored — sending `PUT` for an existing platform updates its URL. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Request Body:** +```json +{ + "platform": "instagram", + "url": "https://instagram.com/example_campus" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `platform` | string | Yes | One of: `instagram`, `linkedin`, `twitter`, `facebook`, `youtube`, `discord`, `github`, `website`, `other` | +| `url` | string (URL) | Yes | Full URL of the social profile/page | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Social link saved successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "platform": ["Invalid platform. Must be one of: instagram, linkedin, twitter, ..."] } } +``` + +--- + +## 20. `social-links//` + +### `DELETE /api/dashboard/campus/social-links//` + +Permanently deletes a social media link belonging to the campus. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Path Params:** +- `link_id` *(string)* — UUID of the `CampusSocialLink` record + +**Request Body:** None + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Social link deleted successfully"] }, + "response": {} +} +``` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["Social link not found"] } } +``` + +--- + +## 21. `/leaderboard/` + +**`GET /api/dashboard/campus//leaderboard/`** + +Returns a paginated, ranked leaderboard of all students in the specified campus, with optional filters. + +**Path Params:** +- `org_id` *(string)* — UUID of the college organisation + +**Query Params:** + +| Param | Type | Description | +|-------|------|-------------| +| `pass_out_year` | string | Filter by graduation year (e.g. `"2025"`) | +| `ig_id` | string | Filter by IG UUID | +| `category` | string | Filter by IG category | +| `is_alumni` | `"true"` / `"false"` | Filter by alumni status | +| `search` | string | Search by full name or muID | +| `pageIndex` | integer | Page number | +| `perPage` | integer | Items per page | +| `sortBy` | string | `full_name`, `muid`, `karma`, `level`, `join_date`, `graduation_year`, `is_alumni` | + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "user_id": "a1b2c3d4-...", + "full_name": "Rahul Krishna", + "muid": "rahul-krishna@mulearn", + "karma": 3400, + "rank": 1, + "level": "Pathfinder", + "join_date": "2023-08-01T10:00:00Z", + "last_karma_gained": "2024-03-10T14:30:00Z", + "department": "Computer Science", + "graduation_year": "2025", + "is_alumni": false, + "ig_count": 3 + } + ], + "pagination": { + "count": 312, + "totalPages": 16, + "isFirst": true, + "isLast": false + } + } +} +``` + +> ⚠️ **Note:** This endpoint currently has a bug — `CampusLeaderboardSerializer` is not yet defined and will raise an error. + +--- + +## 22. `/karma-by-cluster/` + +**`GET /api/dashboard/campus//karma-by-cluster/`** + +Returns total karma and member count grouped by Interest Group category for the specified campus. + +**Path Params:** +- `org_id` *(string)* — UUID of the college organisation + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "technology": { + "total_karma": 28400, + "member_count": 145 + }, + "design": { + "total_karma": 9200, + "member_count": 62 + }, + "unclustered": { + "total_karma": 7600, + "member_count": 105 + } + } +} +``` + +> Members with no IG are grouped under `"unclustered"`. + +--- + +## 23. Campus List + +**`GET /api/dashboard/campus/campus-list/`** + +Returns a simplified, paginated list of all active campuses, intended for dropdown menus or global selectors. + +**Roles:** Any authenticated user. + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "82e6f245-59ea-4be4-a33d-35189a395000", + "title": "Example College of Engineering", + "code": "ECE001" + } + ], + "pagination": { + "count": 450, + "totalPages": 23, + "isFirst": true, + "isLast": false + } + } +} +``` diff --git a/api_docs/Dashboard_Campus_IG_Chapters.md b/api_docs/Dashboard_Campus_IG_Chapters.md new file mode 100644 index 000000000..17cee6ae1 --- /dev/null +++ b/api_docs/Dashboard_Campus_IG_Chapters.md @@ -0,0 +1,344 @@ +# Campus IG Chapters API Documentation + +--- + +## 1. List Campus IG Chapters + +### [GET] /api/v1/dashboard/campus/ig-chapters/ + +**Status:** IMPLEMENTED + +**Purpose:** +Retrieves a list of all active Interest Group (IG) chapters associated with the authenticated user's campus. + +**Roles:** +- Campus Lead +- Lead Enabler + +**Constraints:** +- User must have the required role +- Only returns chapters belonging to the user's organization (`status` active) + +**Authentication:** +Bearer token (JWT) required. + +### Request Body +None + +### Response Body + +**Success Response (200 OK):** + +Structure: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["string"] + }, + "response": [ + { + "id": "string (UUID)", + "ig_id": "string (UUID)", + "ig_name": "string", + "ig_code": "string", + "ig_icon": "string | null", + "lead_id": "string (UUID) | null", + "lead_name": "string | null", + "description": "string | null", + "is_active": "boolean", + "campus_ig_member_count": "integer" + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `response[].id` | string (UUID) | Unique identifier for the chapter | +| `response[].ig_id` | string (UUID) | Unique identifier for the interest group | +| `response[].ig_name` | string | Name of the interest group | +| `response[].ig_code` | string | Code of the interest group | +| `response[].ig_icon` | string or null | URL to the interest group icon | +| `response[].lead_id` | string (UUID) or null | Unique identifier for the assigned chapter lead | +| `response[].lead_name` | string or null | Full name of the assigned chapter lead | +| `response[].description` | string or null | Description of the chapter | +| `response[].is_active` | boolean | Active status of the chapter | +| `response[].campus_ig_member_count` | integer | Number of members in this IG at the campus | + +Example: +```json +{ + "hasError": false, + "statusCode": 200, + "message": {}, + "response": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "ig_id": "b2c3d4e5-f678-90ab-cdef-1234567890ab", + "ig_name": "Web Development", + "ig_code": "WEB", + "ig_icon": "https://mulearn.org/icon.png", + "lead_id": "c3d4e5f6-7890-abcd-ef12-34567890abcd", + "lead_name": "John Doe", + "description": "Learn web development", + "is_active": true, + "campus_ig_member_count": 42 + } + ] +} +``` + +**Error Response (401 / 403 / 404):** +Returns standard error formatted responses for missing organization or invalid permissions. + +**Error Codes:** +- 200: Success +- 401: Unauthorized (invalid token) +- 403: Forbidden (insufficient role) +- 404: User have no organization + +--- + +## 2. Create Campus IG Chapter + +### [POST] /api/v1/dashboard/campus/ig-chapters/ + +**Status:** IMPLEMENTED + +**Purpose:** +Creates a new Interest Group chapter at the authenticated user's campus. It can optionally assign a user as the chapter lead. + +**Roles:** +- Campus Lead +- Lead Enabler + +**Constraints:** +- Only one active chapter per IG per campus is permitted +- If a `lead` is provided, that user must be a member of the campus +- The newly created chapter lead will be automatically assigned the `{ig_code} CampusLead` role +- User must have the required role + +**Authentication:** +Bearer token (JWT) required. + +### Request Body + +**Content-Type:** application/json + +| Parameter | Type | Required | Max Length | Description | +|-----------|------|----------|------------|-------------| +| `ig` | string (UUID) | Yes | 36 | Interest Group ID | +| `lead` | string (UUID) | No | 36 | User ID of the chapter lead | +| `description` | string | No | - | Brief description of the chapter | + +**Example Request:** +```json +{ + "ig": "b2c3d4e5-f678-90ab-cdef-1234567890ab", + "lead": "c3d4e5f6-7890-abcd-ef12-34567890abcd", + "description": "Official Web Dev chapter for our campus" +} +``` + +### Response Body + +**Success Response (200 OK):** + +Structure: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["string"] + }, + "response": {} +} +``` + +Example: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["IG Chapter created successfully"] + }, + "response": {} +} +``` + +**Error Response (400 Bad Request):** + +Example: +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "ig": ["An active chapter for this IG already exists in your campus."] + }, + "response": {} +} +``` + +**Error Codes:** +- 200: Success +- 400: Bad Request (validation failure, duplicate chapter, user not in campus) +- 401: Unauthorized (invalid token) +- 403: Forbidden (insufficient role) +- 404: User have no organization + +--- + +## 3. Update Campus IG Chapter + +### [PATCH] /api/v1/dashboard/campus/ig-chapters/{chapter_id}/ + +**Status:** IMPLEMENTED + +**Purpose:** +Updates an existing IG chapter. Allows modification of description, active status, and the chapter lead. + +**Roles:** +- Campus Lead +- Lead Enabler + +**Constraints:** +- User must have the required role +- Target chapter must belong to the user's campus +- If the `lead` is changed, the new lead must be in the campus +- Role `{ig_code} CampusLead` is automatically revoked from the old lead and granted to the new lead + +**Authentication:** +Bearer token (JWT) required. + +### Request Parameters + +**URL Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `chapter_id` | string (UUID) | Yes | Identifier of the IG chapter | + +### Request Body + +**Content-Type:** application/json + +| Parameter | Type | Required | Max Length | Description | +|-----------|------|----------|------------|-------------| +| `lead` | string (UUID) or null | No | 36 | New user ID for lead, or null to clear | +| `description` | string | No | - | Updated description | +| `is_active` | boolean | No | - | Enable or disable the chapter | + +**Example Request:** +```json +{ + "lead": "d4e5f678-90ab-cdef-1234-567890abcdef", + "description": "Updated Web Dev chapter description" +} +``` + +### Response Body + +**Success Response (200 OK):** + +Structure: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["string"] + }, + "response": {} +} +``` + +Example: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["IG Chapter updated successfully"] + }, + "response": {} +} +``` + +**Error Codes:** +- 200: Success +- 400: Bad Request (validation failure, user not in campus) +- 401: Unauthorized +- 403: Forbidden +- 404: Not Found (chapter not found or user lacks organization) + +--- + +## 4. Delete Campus IG Chapter + +### [DELETE] /api/v1/dashboard/campus/ig-chapters/{chapter_id}/ + +**Status:** IMPLEMENTED + +**Purpose:** +Soft-deletes a campus IG chapter. It deactivates the chapter by setting `is_active=False`, clears the lead, and revokes the associated `{ig_code} CampusLead` role. + +**Roles:** +- Campus Lead +- Lead Enabler + +**Constraints:** +- User must have the required role +- Chapter must belong to the user's campus + +**Authentication:** +Bearer token (JWT) required. + +### Request Parameters + +**URL Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `chapter_id` | string (UUID) | Yes | Identifier of the IG chapter to delete | + +### Request Body +None + +### Response Body + +**Success Response (200 OK):** + +Structure: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["string"] + }, + "response": {} +} +``` + +Example: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["IG Chapter deleted successfully"] + }, + "response": {} +} +``` + +**Error Codes:** +- 200: Success +- 401: Unauthorized +- 403: Forbidden +- 404: Not Found (chapter not found or user lacks organization) diff --git a/api_docs/Dashboard_Campus_Mentor_Access.md b/api_docs/Dashboard_Campus_Mentor_Access.md new file mode 100644 index 000000000..16e1374b3 --- /dev/null +++ b/api_docs/Dashboard_Campus_Mentor_Access.md @@ -0,0 +1,59 @@ +# Dashboard — Campus Mentor Access + +**Base path:** `/api/v1/dashboard/campus/` +**Authorization:** `campus_access_required` in `api/dashboard/campus/dash_campus_helper.py` + +Approved campus mentors are identified in the database, not by the global JWT `Mentor` role alone: + +| Field | Value | +|-------|-------| +| `user_mentor.mentor_tier` | `CAMPUS_MENTOR` | +| `user_mentor.status` | `APPROVED` | +| `user_mentor.org_id` | Same college as `user_organization_link` | + +IG mentors (`MENTOR` JWT + `IG_MENTOR` tier) do **not** receive campus staff APIs. + +--- + +## Access matrix + +| Endpoint | Campus Lead / Enabler | Campus mentor | Notes | +|----------|----------------------|---------------|--------| +| `campus-details/` | Yes | Yes (read) | | +| `home-summary/`, `member-funnel/`, `circle-health/`, `recent-activity/` | Yes | Yes (read) | | +| `student-details/`, `student-details/csv/` | Yes | Yes (read) | | +| `students//activity/` | Yes | Yes (read) | | +| `weekly-karma/` (no `org_id`) | Yes | Yes (read) | | +| `igs/`, `igs//members/` | Yes | Yes (read) | | +| `learning-circles/`, `.../members/` | Yes | Yes (read) | | +| `analytics/karma-trend/`, `analytics/growth/` | Yes | Yes (read) | | +| `events/`, `events/distribution/` | Yes | Yes (read) | | +| `ig-chapters/` GET | Yes | Yes (read) | | +| `showcase/` GET | Yes | Yes (read) | | +| `sessions/list/` | All statuses | All statuses | Students: scheduled only | +| `sessions/create/` | No | Yes | `mentor_only` | +| `assign-mentor/` | Yes | No | Staff only | +| `transfer-*`, `change-student-type/` | Yes | No | Staff only | +| `ig-chapters/` POST/PATCH/DELETE | Yes | No | Staff only | +| `social-links/` PUT/DELETE | Yes | No | Staff only | +| `showcase/` PATCH | Yes | No | Staff only | +| `execom/*` | Yes (varies) | No | Campus Lead for mutations | + +--- + +## Session list visibility + +`can_view_all_campus_session_statuses()` returns true for: + +- Admin (JWT) +- Campus Lead / Lead Enabler +- Approved campus mentor for the user’s college + +Otherwise only `SCHEDULED` sessions are returned. + +--- + +## Related + +- [Dashboard_Campus_Mentor_Sessions.md](./Dashboard_Campus_Mentor_Sessions.md) — session API samples +- [Dashboard_Campus.md](./Dashboard_Campus.md) — full campus API index diff --git a/api_docs/Dashboard_Campus_Mentor_Sessions.md b/api_docs/Dashboard_Campus_Mentor_Sessions.md new file mode 100644 index 000000000..e9fae49a1 --- /dev/null +++ b/api_docs/Dashboard_Campus_Mentor_Sessions.md @@ -0,0 +1,392 @@ +# Dashboard — Campus Mentor & Sessions + +**Base path:** `/api/v1/dashboard/campus/` +**Source:** `api/dashboard/campus/campus_views.py`, `api/dashboard/campus/session_views.py` +**OpenAPI tags:** `Dashboard - Campus`, `Dashboard - Campus Sessions` + +Related docs: [Dashboard_Campus.md](./Dashboard_Campus.md), [Dashboard_Mentor.md](./Dashboard_Mentor.md) (admin session approval: `session/admin/verify//`) + +--- + +## Table of Contents + +| # | Endpoint | Method | Role | +|---|----------|--------|------| +| 1 | [`assign-mentor/`](#1-assign-mentor) | `POST` | Campus Lead, Lead Enabler | +| 2 | [`sessions/create/`](#2-sessionscreate) | `POST` | Mentor (approved Campus Mentor) | +| 3 | [`sessions/list/`](#3-sessionslist) | `GET` | Authenticated user linked to a campus | + +--- + +## Overview + +### Response envelope + +**Success:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Human-readable success message"] }, + "response": {} +} +``` + +**Failure:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Error summary"], + "field_name": ["Validation detail"] + }, + "response": {} +} +``` + +### Authentication + +```http +Authorization: Bearer +``` + +Required on all endpoints below. + +### Pagination & search (`sessions/list/`) + +| Query param | Default | Description | +|-------------|---------|-------------| +| `pageIndex` | `1` | Page number | +| `perPage` | `10` | Items per page | +| `search` | — | Searches `title`, `description` | +| `sortBy` | — | `created_at`, `starts_at` (prefix `-` for descending) | + +### Campus mentor lifecycle + +| Step | Actor | Action | Result | +|------|-------|--------|--------| +| 1 | Campus Lead / Enabler | `POST assign-mentor/` | `UserMentor` row: `mentor_tier=CAMPUS_MENTOR`, `status=PENDING`, `org` = campus | +| 2 | Admin | `PATCH /api/v1/dashboard/mentor/verify//` | `status=APPROVED`; global `Mentor` role assigned | +| 3 | Approved campus mentor | `POST sessions/create/` | Session: `session_type=campus_session`, `status=PENDING_APPROVAL` | +| 4 | Admin | `PATCH /api/v1/dashboard/mentor/session/admin/verify//` | `status=SCHEDULED`; learners can join | + +### Session status values + +| `status` | Meaning | +|----------|---------| +| `PENDING_APPROVAL` | Created; awaiting admin approval | +| `SCHEDULED` | Approved; visible to regular campus students in list | +| `COMPLETED` | Finished | +| `CANCELLED` | Cancelled | +| `REJECTED` | Rejected by admin | + +### Session modes + +`ONLINE`, `OFFLINE`, `HYBRID` + +--- + +## 1. `assign-mentor/` + +**`POST /api/v1/dashboard/campus/assign-mentor/`** + +Nominates an active, non-alumni student at the caller’s college as a **Campus Mentor**. Creates a `user_mentor` record in `PENDING` status scoped to that campus (`org_id`). An admin must approve the mentor before they can create sessions. + +**Roles:** `Campus Lead`, `Lead Enabler` + +**Prerequisites:** + +- Caller is linked to a college via `user_organization_link`. +- Target student exists, belongs to the same campus, and `is_alumni=false`. + +**Request body:** + +```json +{ + "muid": "MU-2024-00123" +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `muid` | Yes | Student’s muLearn ID | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Student successfully nominated as a Campus Mentor"] }, + "response": {} +} +``` + +**Error responses:** + +| Condition | Message | +|-----------|---------| +| Missing `muid` | `muid is required` | +| Caller has no campus | `User have no organization` | +| Unknown `muid` | `Student not found` | +| Student not on caller’s campus / is alumni | `Student is not a member of your campus` | +| Already campus mentor (same org) | `Student is already a Campus Mentor or has a pending request` | +| Other `user_mentor` row exists | `Student is already a mentor with tier ` | + +**Example — already nominated:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Student is already a Campus Mentor or has a pending request"] + }, + "response": {} +} +``` + +**Database record created:** + +| Field | Value | +|-------|-------| +| `mentor_tier` | `CAMPUS_MENTOR` | +| `status` | `PENDING` | +| `org` | Caller’s college organization | +| `user` | Nominated student | + +--- + +## 2. `sessions/create/` + +**`POST /api/v1/dashboard/campus/sessions/create/`** + +Creates a mentorship session for the authenticated user’s campus. The API sets `entity_id` to the user’s college org and `session_type` to `campus_session`. Initial status is `PENDING_APPROVAL` until an admin approves it. + +**Roles:** `Mentor` (JWT role) **and** approved campus mentor record: + +- `user_mentor.status = APPROVED` +- `user_mentor.mentor_tier = CAMPUS_MENTOR` + +**Prerequisites:** + +- User has an active college `user_organization_link`. +- User is an approved Campus Mentor for that org. + +**Request body:** + +Do **not** send `entity_id` or `session_type`; they are set server-side. + +```json +{ + "title": "Career Guidance: Internships", + "description": "Open Q&A on internship applications and interviews.", + "mode": "ONLINE", + "starts_at": "2026-06-15T10:00:00Z", + "ends_at": "2026-06-15T11:30:00Z", + "meeting_link": "https://meet.example.com/abc-defg-hij", + "venue": null, + "max_participants": 50 +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `title` | Yes | Max 150 chars | +| `description` | No | Text | +| `mode` | Yes | `ONLINE`, `OFFLINE`, or `HYBRID` | +| `starts_at` | Yes | ISO 8601 datetime; must be before `ends_at` | +| `ends_at` | Yes | ISO 8601 datetime | +| `meeting_link` | No | Max 500 chars; typical for online/hybrid | +| `venue` | No | Max 255 chars; typical for offline/hybrid | +| `max_participants` | No | Integer cap for joins | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Campus session created successfully and is pending approval."] }, + "response": { + "entity_id": "org-uuid-college", + "session_type": "campus_session", + "title": "Career Guidance: Internships", + "description": "Open Q&A on internship applications and interviews.", + "mode": "ONLINE", + "starts_at": "2026-06-15T10:00:00.000000Z", + "ends_at": "2026-06-15T11:30:00.000000Z", + "meeting_link": "https://meet.example.com/abc-defg-hij", + "venue": null, + "max_participants": 50 + } +} +``` + +**Error responses:** + +| HTTP | Condition | Message | +|------|-----------|---------| +| 403 | Not approved campus mentor | `You are not an approved Campus Mentor.` | +| 404 | No college link | `You are not associated with any campus.` | +| 400 | Validation | Field errors, e.g. start ≥ end | + +**Validation error example:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "non_field_errors": ["Session start time must be before end time."] + }, + "response": {} +} +``` + +--- + +## 3. `sessions/list/` + +**`GET /api/v1/dashboard/campus/sessions/list/`** + +Lists campus mentorship sessions (`session_type=campus_session`) for the authenticated user’s college. Visibility depends on role. + +**Roles:** Any authenticated user with a college `user_organization_link` + +**Query params:** + +| Param | Required | Who can use | Description | +|-------|----------|-------------|-------------| +| `status` | No | Elevated users only | Filter: `PENDING_APPROVAL`, `SCHEDULED`, `COMPLETED`, `CANCELLED`, `REJECTED` | +| `pageIndex` | No | All | Page number | +| `perPage` | No | All | Page size | +| `search` | No | All | Search `title`, `description` | +| `sortBy` | No | All | e.g. `starts_at`, `-created_at` | + +**Visibility rules:** + +| User type | Sessions returned | +|-----------|-------------------| +| **Elevated:** Admin, Campus Lead, Lead Enabler | All non-deleted campus sessions; optional `status` filter | +| **Approved campus mentor** (same org) | Same as elevated | +| **Everyone else** (regular students) | Only `status=SCHEDULED` | + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "session-uuid-001", + "entity_id": "org-uuid-college", + "entity_name": "ABC Engineering College", + "session_type": "campus_session", + "title": "Career Guidance: Internships", + "mode": "ONLINE", + "starts_at": "2026-06-15T10:00:00Z", + "ends_at": "2026-06-15T11:30:00Z", + "status": "SCHEDULED", + "created_by_id": "user-uuid-mentor", + "created_by_name": "Priya Sharma", + "created_at": "2026-06-01T08:00:00Z", + "max_participants": 50 + }, + { + "id": "session-uuid-002", + "entity_id": "org-uuid-college", + "entity_name": "ABC Engineering College", + "session_type": "campus_session", + "title": "Resume Workshop", + "mode": "OFFLINE", + "starts_at": "2026-06-20T14:00:00Z", + "ends_at": "2026-06-20T16:00:00Z", + "status": "PENDING_APPROVAL", + "created_by_id": "user-uuid-mentor", + "created_by_name": "Priya Sharma", + "created_at": "2026-06-02T09:00:00Z", + "max_participants": 30 + } + ], + "pagination": { + "count": 2, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +**Example — campus lead views pending sessions:** + +```http +GET /api/v1/dashboard/campus/sessions/list/?status=PENDING_APPROVAL&pageIndex=1&perPage=10 +``` + +**Example — regular student (only scheduled returned):** + +```http +GET /api/v1/dashboard/campus/sessions/list/ +``` + +Even if `?status=PENDING_APPROVAL` is passed, non-elevated users still receive only `SCHEDULED` sessions. + +**Error response:** + +```json +{ + "hasError": true, + "statusCode": 404, + "message": { + "general": ["You are not associated with any campus."] + }, + "response": {} +} +``` + +--- + +## End-to-end flow + +```mermaid +sequenceDiagram + participant CL as Campus Lead + participant S as Student + participant A as Admin + participant CM as Campus Mentor + participant API as Campus API + + CL->>API: POST assign-mentor/ { muid } + API-->>CL: PENDING campus mentor created + A->>A: PATCH mentor/verify/{mentor_id}/ APPROVED + CM->>API: POST sessions/create/ + API-->>CM: PENDING_APPROVAL session + A->>A: PATCH mentor/session/admin/verify/{id} SCHEDULED + S->>API: GET sessions/list/ + API-->>S: SCHEDULED sessions only + CL->>API: GET sessions/list/?status=PENDING_APPROVAL + API-->>CL: All matching statuses +``` + +--- + +## Related endpoints + +| Action | Endpoint | +|--------|----------| +| Admin approve campus mentor | `PATCH /api/v1/dashboard/mentor/verify//` | +| Admin approve/reject campus session | `PATCH /api/v1/dashboard/mentor/session/admin/verify//` | +| Admin list all sessions | `GET /api/v1/dashboard/mentor/session/admin/list/` | +| Student join scheduled session | `POST /api/v1/dashboard/mentor/session/participation/join//` | +| IG mentor sessions (not campus) | `POST /api/v1/dashboard/mentor/session/create/` | diff --git a/api_docs/Dashboard_Campus_Public_Details.md b/api_docs/Dashboard_Campus_Public_Details.md new file mode 100644 index 000000000..210b5915c --- /dev/null +++ b/api_docs/Dashboard_Campus_Public_Details.md @@ -0,0 +1,120 @@ +# Public Campus Details API Documentation + +--- + +## 1. Get Campus Details by ID + +### [GET] /api/v1/dashboard/campus/{org_id}/ + +**Status:** IMPLEMENTED (Extended with Social Links) + +**Purpose:** +Retrieves the public profile for a specific campus/college. This endpoint has been extended to include the campus's official social media links. It is publicly accessible to any authenticated user across the platform. + +**Roles:** +- Authenticated user (any role) + +**Constraints:** +- The requested `org_id` must validly exist as a College organization. + +**Authentication:** +Bearer token (JWT) required. + +### Request Parameters + +**URL Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `org_id` | string (UUID) | Yes | Identifier of the campus organization | + +### Request Body +None + +### Response Body + +**Success Response (200 OK):** + +Structure: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["string"] + }, + "response": { + "org_id": "string (UUID)", + "college_name": "string", + "campus_code": "string", + "campus_zone": "string", + "campus_level": "integer", + "total_karma": "integer", + "total_members": "integer", + "active_members": "integer", + "rank": "integer", + "social_links": [ + { + "id": "string (UUID)", + "platform": "string", + "url": "string" + } + ] + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `response.org_id` | string (UUID) | Unique ID of the college/campus | +| `response.college_name` | string | Name of the college/campus | +| `response.campus_code` | string | The unique MULEARN campus code | +| `response.campus_zone` | string | Geographical zone of the campus | +| `response.campus_level` | integer | Active tier/level of the campus | +| `response.total_karma` | integer | Total karma amassed by members | +| `response.total_members` | integer | Total registered users at campus | +| `response.active_members` | integer | Actively engaged members in the last 6 months | +| `response.rank` | integer | Sub-platform leaderboard rank | +| `response.social_links` | array of objects | List of official social media links for the campus | +| `response.social_links[].id` | string (UUID) | Unique ID of the social link | +| `response.social_links[].platform` | string | The social media platform (e.g., `instagram`) | +| `response.social_links[].url` | string (URL) | The full link to the social profile | + +Example: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["Campus details fetched successfully"] + }, + "response": { + "org_id": "82e6f245-59ea-4be4-a33d-35189a395000", + "college_name": "Example Engineering College", + "campus_code": "EEC", + "campus_zone": "Central", + "campus_level": 2, + "total_karma": 45000, + "total_members": 350, + "active_members": 120, + "rank": 4, + "social_links": [ + { + "id": "e5f67890-abcd-ef12-3456-7890abcdef12", + "platform": "instagram", + "url": "https://instagram.com/eec_mulearn" + }, + { + "id": "f5f67890-abcd-ef12-3456-7890abcdef13", + "platform": "linkedin", + "url": "https://linkedin.com/company/eec-mulearn" + } + ] + } +} +``` + +**Error Codes:** +- 200: Success +- 401: Unauthorized (invalid or missing token) +- 404: Not Found (organization ID does not exist) diff --git a/api_docs/Dashboard_Campus_Social_Links.md b/api_docs/Dashboard_Campus_Social_Links.md new file mode 100644 index 000000000..9655f39d3 --- /dev/null +++ b/api_docs/Dashboard_Campus_Social_Links.md @@ -0,0 +1,171 @@ +# Campus Social Links API Documentation + +--- + +## 1. Upsert Campus Social Link + +### [PUT] /api/v1/dashboard/campus/social-links/ + +**Status:** IMPLEMENTED + +**Purpose:** +Creates or updates a social media link for the authenticated user's campus. If a link for the specified platform already exists for the campus, it will be updated with the new URL. Otherwise, a new link will be created. + +**Roles:** +- Campus Lead +- Lead Enabler + +**Constraints:** +- User must have the required role +- Only one link per platform per campus is permitted +- The `platform` must be one of the supported social platforms + +**Authentication:** +Bearer token (JWT) required. + +### Request Body + +**Content-Type:** application/json + +| Parameter | Type | Required | Max Length | Description | +|-----------|------|----------|------------|-------------| +| `platform` | string (enum) | Yes | 20 | The social media platform (e.g., `instagram`, `linkedin`, `twitter`, `facebook`, `youtube`, `github`, `website`) | +| `url` | string | Yes | 500 | The full URL to the campus social media page | + +**Example Request:** +```json +{ + "platform": "instagram", + "url": "https://instagram.com/mulearn_campus" +} +``` + +### Response Body + +**Success Response (200 OK):** + +Structure: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["string"] + }, + "response": {} +} +``` + +Example: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["Social link saved successfully"] + }, + "response": {} +} +``` + +**Error Response (400 Bad Request):** + +Example: +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "platform": ["Invalid platform."] + }, + "response": {} +} +``` + +**Error Codes:** +- 200: Success +- 400: Bad Request (validation failure, invalid platform) +- 401: Unauthorized (invalid token) +- 403: Forbidden (insufficient role) +- 404: User have no organization + +--- + +## 2. Delete Campus Social Link + +### [DELETE] /api/v1/dashboard/campus/social-links/{link_id}/ + +**Status:** IMPLEMENTED + +**Purpose:** +Hard-deletes a specific campus social link. The social link record is permanently removed from the database. + +**Roles:** +- Campus Lead +- Lead Enabler + +**Constraints:** +- User must have the required role +- Target social link must belong to the user's campus + +**Authentication:** +Bearer token (JWT) required. + +### Request Parameters + +**URL Parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `link_id` | string (UUID) | Yes | Identifier of the social link to delete | + +### Request Body +None + +### Response Body + +**Success Response (200 OK):** + +Structure: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["string"] + }, + "response": {} +} +``` + +Example: +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["Social link deleted successfully"] + }, + "response": {} +} +``` + +**Error Response (404 Not Found):** + +Example: +```json +{ + "hasError": true, + "statusCode": 404, + "message": { + "general": ["Social link not found"] + }, + "response": {} +} +``` + +**Error Codes:** +- 200: Success +- 401: Unauthorized +- 403: Forbidden +- 404: Not Found (social link not found or user lacks organization) diff --git a/api_docs/Dashboard_Channels.md b/api_docs/Dashboard_Channels.md new file mode 100644 index 000000000..c265543d4 --- /dev/null +++ b/api_docs/Dashboard_Channels.md @@ -0,0 +1,34 @@ +# Dashboard / Channels + + +Base path: `/api/dashboard/channels/` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:channel_id` +- Request body example (JSON): +```json +{ + "channel_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_College.md b/api_docs/Dashboard_College.md new file mode 100644 index 000000000..c6afc0766 --- /dev/null +++ b/api_docs/Dashboard_College.md @@ -0,0 +1,60 @@ +# Dashboard / College + + +Base path: `/api/dashboard/college/` + + +## Endpoint: `change-college/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:college_code` +- Request body example (JSON): +```json +{ + "college_code": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Company.md b/api_docs/Dashboard_Company.md new file mode 100644 index 000000000..59a29caa7 --- /dev/null +++ b/api_docs/Dashboard_Company.md @@ -0,0 +1,1040 @@ +# Dashboard — Company API + +**Base path:** `/api/v1/dashboard/company/` +**Source:** `api/dashboard/company/` +**OpenAPI tag:** `Dashboard - Company`, `Dashboard - Company Jobs`, `Dashboard - Company Analytics`, `Public - Company`, `Public - Jobs` + +--- + +## Table of Contents + +| # | Endpoint | Method(s) | Auth / Role | +|---|----------|-----------|-------------| +| 1 | [`register/`](#1-register) | `POST`, `PATCH` | Authenticated user | +| 2 | [`status/`](#2-status) | `GET` | Authenticated user | +| 3 | [`profile/`](#3-profile) | `GET`, `PATCH` | Company | +| 4 | [`profile/public//`](#4-profilepublicslug) | `GET` | None (public) | +| 5 | [`profile/public//jobs/`](#5-profilepublicslugjobs) | `GET` | None (public) | +| 6 | [`list/`](#6-list) | `GET` | Admin | +| 7 | [`/`](#7-company_id) | `GET` | Admin | +| 8 | [`verify//`](#8-verifycompany_id) | `PATCH` | Admin | +| 9 | [`jobs/`](#9-jobs) | `GET`, `POST` | Company | +| 10 | [`jobs/all/`](#10-jobsall) | `GET` | Authenticated user | +| 11 | [`jobs//`](#11-jobsjob_id) | `GET`, `PATCH`, `DELETE` | Company | +| 12 | [`jobs//apply/`](#12-jobsjob_idapply) | `POST` | Authenticated user | +| 13 | [`jobs//applications/`](#13-jobsjob_idapplications) | `GET` | Company | +| 14 | [`applications/me/`](#14-applicationsme) | `GET` | Authenticated user | +| 15 | [`applications//status/`](#15-applicationsapp_idstatus) | `PATCH` | Company | +| 16 | [`applications//withdraw/`](#16-applicationsapp_idwithdraw) | `DELETE` | Authenticated user (owner) | +| 17 | [`applications//resubmit/`](#17-applicationsapp_idresubmit) | `PATCH` | Authenticated user (owner) | +| 18 | [`mulearners/`](#18-mulearners) | `GET` | Company | +| 19 | [`analytics/gigs/`](#19-analyticsgigs) | `GET` | Company | + +**Company tasks & admin approval:** see [Dashboard_Company_Tasks.md](./Dashboard_Company_Tasks.md) (`tasks/`, `tasks//`, admin `task/pending/` & `task//review/`). + +--- + +## Overview + +### Response envelope + +All endpoints return a `CustomResponse` wrapper: + +**Success:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Human-readable success message"] }, + "response": {} +} +``` + +**Failure:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Error summary"], + "field_name": ["Validation detail"] + }, + "response": {} +} +``` + +HTTP status codes follow `statusCode` in the body (typically `400` or `404` on failure). + +### Authentication + +Most endpoints require a JWT: + +```http +Authorization: Bearer +``` + +Exceptions are noted per endpoint (`permission_classes = []`). + +### Pagination & search + +List endpoints use `CommonUtils.get_paginated_queryset`: + +| Query param | Default | Description | +|-------------|---------|-------------| +| `pageIndex` | `1` | Page number | +| `perPage` | `10` | Items per page | +| `search` | — | Case-insensitive search (fields vary per endpoint) | +| `sortBy` | — | Sort key; prefix with `-` for descending | + +**Paginated response shape:** + +```json +{ + "response": { + "data": [], + "pagination": { + "count": 42, + "totalPages": 5, + "isNext": true, + "isPrev": false, + "nextPage": 2 + } + } +} +``` + +### Company lifecycle + +| `status` value | Meaning | +|----------------|---------| +| `pending` | Awaiting admin verification | +| `verified` | Approved; `Company` role assigned; profile endpoints available | +| `rejected` | Rejected; can PATCH `register/` to resubmit | + +--- + +## 1. `register/` + +**`POST /api/v1/dashboard/company/register/`** + +Submit a new company registration for the authenticated user. One registration per user. + +**Roles:** Any authenticated user (no `Company` role required yet) + +**Request body:** + +```json +{ + "name": "Acme Labs", + "logo": "https://cdn.example.com/logo.png", + "description": "We build developer tools for campuses.", + "short_pitch": "A short pitch under 150 words.", + "industry_sector": "Technology", + "website_link": "https://acme.com", + "email": "contact@acme.com", + "location": "Kochi, Kerala", + "district_id": "district-uuid", + "state_id": "state-uuid", + "country_id": "country-uuid", + "legal_name": "Acme Labs Pvt Ltd", + "registration_number": "REG-12345", + "tax_id": "GSTIN-12345", + "company_size": "51-200", + "linkedin_url": "https://linkedin.com/company/acme", + "founded_year": 2018, + "remote_policy": "Hybrid", + "culture_text": "We move fast and care deeply.", + "tech_stack": ["Python", "React", "PostgreSQL"], + "perks": ["Health Insurance", "Remote Fridays"], + "testimonials": [], + "gallery": [] +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `name` | Yes | Unique company name; slug auto-generated | +| `description` | Yes | Company description | +| Other fields | No | `short_pitch` max 150 words | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Company registration submitted successfully."] }, + "response": { + "name": "Acme Labs", + "logo": "https://cdn.example.com/logo.png", + "description": "We build developer tools for campuses.", + "short_pitch": "A short pitch under 150 words.", + "industry_sector": "Technology", + "website_link": "https://acme.com", + "email": "contact@acme.com", + "location": "Kochi, Kerala", + "district_id": "district-uuid", + "state_id": "state-uuid", + "country_id": "country-uuid", + "legal_name": "Acme Labs Pvt Ltd", + "registration_number": "REG-12345", + "tax_id": "GSTIN-12345", + "company_size": "51-200", + "linkedin_url": "https://linkedin.com/company/acme", + "founded_year": 2018, + "remote_policy": "Hybrid", + "culture_text": "We move fast and care deeply.", + "tech_stack": ["Python", "React", "PostgreSQL"], + "perks": ["Health Insurance", "Remote Fridays"], + "testimonials": [], + "gallery": [] + } +} +``` + +**Common errors:** Registration already exists for this account. + +--- + +**`PATCH /api/v1/dashboard/company/register/`** + +Update a pending or rejected registration. If status is `rejected`, saving resets status to `pending` and clears `rejection_reason`. + +**Request body:** Same fields as POST (partial update supported). + +**Success response:** Updated registration fields (same shape as POST response). + +**Errors:** No registration found; company already `verified` (use `profile/` instead). + +--- + +## 2. `status/` + +**`GET /api/v1/dashboard/company/status/`** + +Check onboarding status for the authenticated user's company request. + +**Roles:** Authenticated user + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "status": "pending", + "rejection_reason": null, + "company_id": "company-uuid", + "name": "Acme Labs", + "slug": "acme-labs" + } +} +``` + +--- + +## 3. `profile/` + +**`GET /api/v1/dashboard/company/profile/`** + +Full company profile for the logged-in company user. + +**Roles:** `Company` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "id": "company-uuid", + "company_user": "user-uuid", + "company_user_name": "Jane Doe", + "company_user_email": "jane@example.com", + "name": "Acme Labs", + "slug": "acme-labs", + "logo": "https://cdn.example.com/logo.png", + "description": "We build developer tools.", + "short_pitch": "Short pitch text.", + "industry_sector": "Technology", + "website_link": "https://acme.com", + "email": "contact@acme.com", + "status": "verified", + "location": "Kochi, Kerala", + "district": "district-uuid", + "district_name": "Ernakulam", + "state": "state-uuid", + "country": "country-uuid", + "legal_name": "Acme Labs Pvt Ltd", + "registration_number": "REG-12345", + "tax_id": "GSTIN-12345", + "company_size": "51-200", + "linkedin_url": "https://linkedin.com/company/acme", + "verification_document_url": null, + "verification_requested_at": "2026-01-05T10:00:00Z", + "verified_at": "2026-01-10T09:00:00Z", + "verified_by": "admin-uuid", + "rejection_reason": null, + "created_at": "2026-01-01T10:00:00Z", + "updated_at": "2026-05-01T10:00:00Z", + "deleted_at": null, + "updated_by": "user-uuid", + "deleted_by": null, + "founded_year": 2018, + "remote_policy": "Hybrid", + "culture_text": "Culture text.", + "tech_stack": ["Python", "Django"], + "perks": ["WFH"], + "testimonials": [], + "gallery": [] + } +} +``` + +--- + +**`PATCH /api/v1/dashboard/company/profile/`** + +Update verified company profile (partial). + +**Request example:** + +```json +{ + "description": "Updated description.", + "tech_stack": ["Python", "Django", "Next.js"], + "perks": ["Health Insurance", "WFH"] +} +``` + +**Success response:** Updated profile object (same shape as GET). + +--- + +## 4. `profile/public//` + +**`GET /api/v1/dashboard/company/profile/public//`** + +Public company profile. Only companies with `status = verified`. + +**Auth:** None + +**Path params:** `slug` — company URL slug (e.g. `acme-labs`) + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "id": "company-uuid", + "name": "Acme Labs", + "slug": "acme-labs", + "logo": "https://cdn.example.com/logo.png", + "description": "We build developer tools.", + "short_pitch": "Short pitch.", + "industry_sector": "Technology", + "website_link": "https://acme.com", + "email": "contact@acme.com", + "location": "Kochi, Kerala", + "district_name": "Ernakulam", + "state_name": "Kerala", + "country_name": "India", + "company_size": "51-200", + "linkedin_url": "https://linkedin.com/company/acme", + "founded_year": 2018, + "remote_policy": "Hybrid", + "culture_text": "Culture text.", + "tech_stack": ["Python"], + "perks": ["WFH"], + "testimonials": [], + "gallery": [] + } +} +``` + +--- + +## 5. `profile/public//jobs/` + +**`GET /api/v1/dashboard/company/profile/public//jobs/`** + +Active jobs for a verified company (paginated). + +**Auth:** None + +**Query params:** `pageIndex`, `perPage`, `search`, `sortBy` (`title`, `created_at`) + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "job-uuid", + "company_name": "Acme Labs", + "company_logo": "https://cdn.example.com/logo.png", + "title": "Backend Engineer", + "experience": "1-3 years", + "job_description": "Build REST APIs.", + "location": "Kochi", + "salary_range": "6-10 LPA", + "job_type": "Full-Time", + "status": "Active", + "duration_value": null, + "duration_unit": null, + "hourly_rate": null, + "deliverables": null, + "stipend": null, + "certificate_provided": null, + "rules": [ + { "id": "rule-uuid", "rule_type": "min_karma", "rule_value": "1000" } + ], + "created_at": "2026-05-01T10:00:00Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +## 6. `list/` + +**`GET /api/v1/dashboard/company/list/`** + +Admin list of all companies with filters. + +**Roles:** `Admin` + +**Query params:** + +| Param | Description | +|-------|-------------| +| `status` | `pending`, `verified`, `rejected` | +| `industry_sector` | Exact match | +| `company_size` | Exact match | +| `district` | District name | +| `state` | State name | +| `country` | Country name | +| `pageIndex`, `perPage`, `search`, `sortBy` | Pagination / search (`name`, `slug`, `email`, `industry_sector`; sort: `name`, `status`, `created_at`) | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "company-uuid", + "name": "Acme Labs", + "slug": "acme-labs", + "status": "pending", + "email": "contact@acme.com", + "company_user_id": "user-uuid", + "company_user_name": "Jane Doe", + "industry_sector": "Technology", + "company_size": "51-200", + "location": "Kochi", + "district_name": "Ernakulam", + "state_name": "Kerala", + "country_name": "India", + "verification_requested_at": "2026-01-05T10:00:00Z", + "verified_at": null + } + ], + "pagination": { "count": 1, "totalPages": 1, "isNext": false, "isPrev": false, "nextPage": null } + } +} +``` + +--- + +## 7. `/` + +**`GET /api/v1/dashboard/company//`** + +Admin detail view for one company. + +**Roles:** `Admin` + +**Success response:** Full company object (same shape as [profile GET](#3-profile)). + +--- + +## 8. `verify//` + +**`PATCH /api/v1/dashboard/company/verify//`** + +Approve or reject a pending company. On `verified`, creates a `Company` organisation and assigns the `Company` role to the POC user. + +**Roles:** `Admin` + +**Request body — Approve:** + +```json +{ + "status": "verified" +} +``` + +**Request body — Reject:** + +```json +{ + "status": "rejected", + "rejection_reason": "Incomplete verification documents." +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `status` | Yes | `verified` or `rejected` | +| `rejection_reason` | Yes if rejected | Required when `status` is `rejected` | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Company status updated to verified successfully."] }, + "response": {} +} +``` + +--- + +## 9. `jobs/` + +**`POST /api/v1/dashboard/company/jobs/`** + +Create a job or gig for the authenticated company. + +**Roles:** `Company` + +**Request body:** + +```json +{ + "title": "Backend Engineer", + "experience": "1-3 years", + "job_description": "Build and maintain REST APIs.", + "location": "Kochi", + "salary_range": "6-10 LPA", + "job_type": "Full-Time", + "status": "Active", + "duration_value": null, + "duration_unit": null, + "hourly_rate": null, + "deliverables": null, + "stipend": null, + "certificate_provided": null, + "rules": [ + { "rule_type": "min_karma", "rule_value": "1000" }, + { "rule_type": "min_level", "rule_value": "3" } + ] +} +``` + +| Field | Notes | +|-------|-------| +| `job_type` | `Hybrid`, `Full-Time`, `Remote`, `Part-Time`, `Internship`, `Gig` | +| `status` | `Draft`, `Active`, `Closed`, `Expired` | +| `duration_unit` | `days`, `weeks`, `months` (for Gig / Internship) | +| `certificate_provided` | `Yes` or `No` | +| `rules` | Optional array; `rule_type`: `min_karma`, `max_karma`, `min_level`, `max_level`, etc. | + +**Gig example:** + +```json +{ + "title": "API Integration Gig", + "job_type": "Gig", + "status": "Active", + "duration_value": 2, + "duration_unit": "weeks", + "hourly_rate": "500.00", + "deliverables": ["Integrate payment webhook", "Write API docs"], + "rules": [{ "rule_type": "min_karma", "rule_value": "500" }] +} +``` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Job posted successfully."] }, + "response": { + "id": "job-uuid", + "title": "Backend Engineer", + "experience": "1-3 years", + "job_description": "Build and maintain REST APIs.", + "location": "Kochi", + "salary_range": "6-10 LPA", + "job_type": "Full-Time", + "status": "Active", + "duration_value": null, + "duration_unit": null, + "hourly_rate": null, + "deliverables": null, + "stipend": null, + "certificate_provided": null, + "rules": [ + { "id": "rule-uuid", "rule_type": "min_karma", "rule_value": "1000" } + ] + } +} +``` + +--- + +**`GET /api/v1/dashboard/company/jobs/`** + +List jobs for the logged-in company (non-deleted). + +**Roles:** `Company` + +**Query params:** `pageIndex`, `perPage`, `search` (`title`, `location`, `job_type`), `sortBy` (`title`, `created_at`) + +**Success response:** Paginated `data` array of job objects (same fields as POST response plus `company_name`, `company_logo`, `created_at`). + +--- + +## 10. `jobs/all/` + +**`GET /api/v1/dashboard/company/jobs/all/`** + +Public catalogue of all active jobs across companies. + +**Roles:** Authenticated user + +**Query params:** `pageIndex`, `perPage`, `search`, `sortBy` + +**Success response:** Paginated job list (same item shape as [jobs GET](#9-jobs)). + +--- + +## 11. `jobs//` + +**`GET /api/v1/dashboard/company/jobs//`** + +Single job detail (company must own the job). + +**Roles:** `Company` + +**Success response:** Single job object (not wrapped in `data`). + +--- + +**`PATCH /api/v1/dashboard/company/jobs//`** + +Update a job. Replacing `rules` deletes existing rules and recreates them. + +**Request example:** + +```json +{ + "status": "Closed", + "salary_range": "8-12 LPA", + "rules": [ + { "rule_type": "min_karma", "rule_value": "1500" } + ] +} +``` + +**Success response:** Updated job fields. + +--- + +**`DELETE /api/v1/dashboard/company/jobs//`** + +Soft-delete a job (`is_deleted = true`). + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Job deleted successfully."] }, + "response": {} +} +``` + +--- + +## 12. `jobs//apply/` + +**`POST /api/v1/dashboard/company/jobs//apply/`** + +Apply to an active job. Eligibility rules on the job are validated (karma / level). + +**Roles:** Authenticated user (learner) + +**Request body:** + +```json +{ + "resume_link": "https://storage.example.com/resume.pdf", + "cover_letter": "I am excited to apply for this role." +} +``` + +| Field | Required | +|-------|----------| +| `resume_link` | No | +| `cover_letter` | No | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Application submitted successfully."] }, + "response": {} +} +``` + +**Common errors:** Job not active; duplicate application; insufficient karma/level per rules. + +--- + +## 13. `jobs//applications/` + +**`GET /api/v1/dashboard/company/jobs//applications/`** + +List applicants for a job owned by the company. + +**Roles:** `Company` + +**Query params:** `pageIndex`, `perPage`, `search`, `sortBy` (`applied_at`, `status`) + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "application-uuid", + "job": "job-uuid", + "applicant_name": "Riya Sharma", + "applicant_email": "riya@example.com", + "resume_link": "https://storage.example.com/resume.pdf", + "cover_letter": "I am excited to apply.", + "status": "Pending", + "rejection_reason": null, + "applied_at": "2026-05-10T10:00:00Z" + } + ], + "pagination": { "count": 1, "totalPages": 1, "isNext": false, "isPrev": false, "nextPage": null } + } +} +``` + +**Application status values:** `Pending`, `In-Review`, `Shortlisted`, `Interview`, `Rejected`, `Selected` + +--- + +## 14. `applications/me/` + +**`GET /api/v1/dashboard/company/applications/me/`** + +List all jobs the current user has applied to. + +**Roles:** Authenticated user + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "application-uuid", + "job": { + "id": "job-uuid", + "company_name": "Acme Labs", + "company_logo": "https://cdn.example.com/logo.png", + "title": "Backend Engineer", + "job_type": "Full-Time", + "status": "Active", + "rules": [] + }, + "resume_link": "https://storage.example.com/resume.pdf", + "cover_letter": "Cover letter text.", + "status": "Shortlisted", + "rejection_reason": null, + "applied_at": "2026-05-10T10:00:00Z" + } + ], + "pagination": { "count": 1, "totalPages": 1, "isNext": false, "isPrev": false, "nextPage": null } + } +} +``` + +--- + +## 15. `applications//status/` + +**`PATCH /api/v1/dashboard/company/applications//status/`** + +Company updates an application's status or rejection reason. + +**Roles:** `Company` + +**Request body:** + +```json +{ + "status": "Shortlisted", + "rejection_reason": null +} +``` + +**Reject example:** + +```json +{ + "status": "Rejected", + "rejection_reason": "Profile does not match required experience." +} +``` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Application status updated successfully."] }, + "response": { + "id": "application-uuid", + "job": "job-uuid", + "applicant_name": "Riya Sharma", + "applicant_email": "riya@example.com", + "resume_link": "https://storage.example.com/resume.pdf", + "cover_letter": "Cover letter.", + "status": "Shortlisted", + "rejection_reason": null, + "applied_at": "2026-05-10T10:00:00Z" + } +} +``` + +--- + +## 16. `applications//withdraw/` + +**`DELETE /api/v1/dashboard/company/applications//withdraw/`** + +Applicant withdraws their application (hard delete). + +**Roles:** Authenticated user (must own the application) + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Application withdrawn successfully."] }, + "response": {} +} +``` + +--- + +## 17. `applications//resubmit/` + +**`PATCH /api/v1/dashboard/company/applications//resubmit/`** + +Resubmit a rejected application. Sets status back to `Pending` and clears `rejection_reason`. + +**Roles:** Authenticated user (must own the application) + +**Request body:** + +```json +{ + "resume_link": "https://storage.example.com/resume-v2.pdf", + "cover_letter": "Updated cover letter with more detail." +} +``` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Application resubmitted successfully."] }, + "response": {} +} +``` + +**Error:** Only applications with `status = Rejected` can be resubmitted. + +--- + +## 18. `mulearners/` + +**`GET /api/v1/dashboard/company/mulearners/`** + +Talent directory of public muLearn users for company discovery. + +**Roles:** `Company` + +**Query params:** + +| Param | Description | +|-------|-------------| +| `min_karma` | Minimum karma | +| `max_karma` | Maximum karma | +| `level` | Level order (integer) | +| `college` | College name (contains) | +| `department` | Department title (contains) | +| `graduation_year` | Graduation year | +| `ig` | Interest group name (contains) | +| `skill` | Skill UUID | +| `achievement` | Achievement UUID | +| `task` | Task UUID (users with karma log for task) | +| `pageIndex`, `perPage`, `search`, `sortBy` | Search `full_name`, `muid`, `email`; sort `full_name`, `created_at`, `karma` | + +Only users with `is_public = true` in user settings are returned. + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "user-uuid", + "full_name": "Riya Sharma", + "muid": "riya-sharma@mulearn", + "email": "riya@example.com", + "karma": 3200, + "level": 4, + "college": "Example College of Engineering", + "department": "Computer Science", + "graduation_year": 2026 + } + ], + "pagination": { "count": 50, "totalPages": 5, "isNext": true, "isPrev": false, "nextPage": 2 } + } +} +``` + +--- + +## 19. `analytics/gigs/` + +**`GET /api/v1/dashboard/company/analytics/gigs/`** + +Analytics for gig-type jobs posted by the company. + +**Roles:** `Company` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "total_gigs_posted": 8, + "active_gigs": 3, + "closed_gigs": 2, + "average_hourly_rate": 450.5, + "application_funnel": { + "Total": 24, + "Pending": 10, + "In-Review": 4, + "Shortlisted": 3, + "Interview": 2, + "Selected": 2, + "Rejected": 3 + }, + "conversion_rate": "8.33%" + } +} +``` + +--- + +## Usage flows + +### Company onboarding + +```mermaid +sequenceDiagram + participant U as User + participant API as Company API + participant Admin as Admin + + U->>API: POST /register/ (JWT) + API-->>U: status pending + U->>API: GET /status/ + Admin->>API: PATCH /verify/{id}/ verified + U->>API: GET /profile/ (Company role) +``` + +### Job application (learner) + +```mermaid +sequenceDiagram + participant L as Learner + participant API as Company API + participant Co as Company + + L->>API: GET /jobs/all/ + L->>API: POST /jobs/{id}/apply/ + Co->>API: GET /jobs/{id}/applications/ + Co->>API: PATCH /applications/{id}/status/ +``` + +--- + +## Related + +- Interactive schema: `/api/docs/` (when `ENABLE_SWAGGER=true`) +- OpenAPI: `/api/schema/` diff --git a/api_docs/Dashboard_Company_Mentor.md b/api_docs/Dashboard_Company_Mentor.md new file mode 100644 index 000000000..d29929345 --- /dev/null +++ b/api_docs/Dashboard_Company_Mentor.md @@ -0,0 +1,1285 @@ +# Company Mentor — API Reference + +> **Base URL prefix** assumed throughout this document: +> - Company endpoints → `/api/v1/dashboard/company/` +> - Mentor endpoints → `/api/v1/dashboard/mentor/` +> - Calendar endpoints → `/api/v1/calendar/` +> - Leaderboard endpoints → `/api/v1/leaderboard/` +> +> All authenticated endpoints require `Authorization: Bearer ` header unless noted as public. + +**Source:** `api/dashboard/company/`, `api/dashboard/mentor/`, `api/calendar/`, `api/leaderboard/` + +**Related docs:** [Dashboard_Company.md](./Dashboard_Company.md), [Dashboard_Company_Tasks.md](./Dashboard_Company_Tasks.md), [Dashboard_Mentor.md](./Dashboard_Mentor.md) + +--- + +## Response envelope + +All endpoints return a `CustomResponse` wrapper: + +**Success:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Human-readable success message"] }, + "response": {} +} +``` + +**Failure:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Error summary"], + "field_name": ["Validation detail"] + }, + "response": {} +} +``` + +HTTP status codes follow `statusCode` in the body (typically `400`, `403`, or `404` on failure). + +### Pagination & search + +List endpoints use `CommonUtils.get_paginated_queryset`: + +| Query param | Default | Description | +|-------------|---------|-------------| +| `pageIndex` | `1` | Page number | +| `perPage` | `10` | Items per page | +| `search` | — | Case-insensitive search (fields vary per endpoint) | +| `sortBy` | — | Sort key; prefix with `-` for descending | + +**Paginated response shape:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [], + "pagination": { + "count": 42, + "totalPages": 5, + "isNext": true, + "isPrev": false, + "nextPage": 2 + } + } +} +``` + +--- + +## Table of Contents + +1. [Company Mentor — Nomination](#1-company-mentor--nomination) +2. [Admin — Mentor Approval](#2-admin--mentor-approval) +3. [Company Management (Extended)](#3-company-management-extended) + - [Profile](#31-company-profile) + - [Jobs](#32-company-jobs) + - [Applications](#33-job-applications) + - [MuLearner Directory](#34-mulearner-directory) + - [Gig Analytics](#35-gig-analytics) + - [Tasks](#36-task-management) +4. [Mentor Sessions (Extended)](#4-mentor-sessions-extended) + - [Create Session](#41-create-session) + - [Available Sessions](#42-available-sessions-for-learners) +5. [Calendar](#5-calendar) + - [Company Session Calendar](#51-company-session-calendar) + - [IG Mentor Session Calendar](#52-ig-mentor-session-calendar) + - [Campus Mentor Session Calendar](#53-campus-mentor-session-calendar) +6. [Leaderboard](#6-leaderboard) + - [IG Mentor Leaderboard](#61-ig-mentor-leaderboard) + - [Campus Mentor Leaderboard](#62-campus-mentor-leaderboard) +7. [Event Management](#7-event-management) + +--- + +## 1. Company Mentor — Nomination + +> **Who can nominate?** Only the **verified company creator** (user with the `Company` role linked to a verified company). +> +> The nominated user must already be a member of the company's Organisation record (`UserOrganizationLink`) **before** nomination. +> +> After nomination the record enters `PENDING` state until an admin approves via `PATCH /dashboard/mentor/verify//`. +> +> **Approved Company Mentors cannot nominate** — nomination endpoints require the `Company` role, which is assigned to the company creator on verification, not to nominated mentors. + +--- + +### `POST /api/v1/dashboard/company/mentor/nominate/` + +Nominate a platform user (identified by their `muid`) as a Company Mentor for your company. + +**Auth:** JWT · `Company` role · verified company profile + +**Request body** + +```json +{ + "muid": "john-doe@mulearn", + "reason": "Senior developer, deeply involved in company projects." +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `muid` | string | ✅ | MuID of the user to nominate (e.g. `john-doe@mulearn`) | +| `reason` | string | ❌ | Optional reason / recommendation note | + +**Validation rules** + +- `muid` must resolve to an existing platform user +- The resolved user must have a `UserOrganizationLink` entry for this company's org +- No active (non-`REJECTED`) `COMPANY_MENTOR` nomination must already exist for that user + company org + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["User nominated as Company Mentor. Pending admin approval."] }, + "response": { + "id": "a1b2c3d4-...", + "user_id": "u-uuid-here", + "user_name": "John Doe", + "user_email": "john@example.com", + "org_name": "Acme Corp", + "mentor_tier": "COMPANY_MENTOR", + "status": "PENDING", + "reason": "Senior developer, deeply involved in company projects.", + "verification_note": null, + "verified_at": null + } +} +``` + +**Error responses** + +| HTTP | Scenario | +|---|---| +| `403` | No verified company profile for caller (`You must have a verified company profile to nominate mentors.`) | +| `400` | `muid` not found on platform | +| `400` | User is not a member of this company's organisation | +| `400` | User already has an active nomination for this company | + +--- + +### `GET /api/v1/dashboard/company/mentor/list/` + +List all Company Mentor nominations for the authenticated company. + +**Auth:** JWT · `Company` role · verified company profile (creator only) + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": [ + { + "id": "a1b2c3d4-...", + "user_id": "u-uuid-here", + "user_name": "John Doe", + "user_email": "john@example.com", + "org_name": "Acme Corp", + "mentor_tier": "COMPANY_MENTOR", + "status": "PENDING", + "reason": "Senior developer.", + "verification_note": null, + "verified_at": null + }, + { + "id": "e5f6g7h8-...", + "user_id": "u-uuid-2", + "user_name": "Jane Smith", + "user_email": "jane@example.com", + "org_name": "Acme Corp", + "mentor_tier": "COMPANY_MENTOR", + "status": "APPROVED", + "reason": "Team lead.", + "verification_note": "Verified after background check.", + "verified_at": "2026-06-02T10:00:00Z" + } + ] +} +``` + +> **Note:** The list is returned as a bare array in `response`, not wrapped in `data` / `pagination`. + +--- + +## 2. Admin — Mentor Approval + +> **Existing endpoint extended.** `PATCH /api/v1/dashboard/mentor/verify//` handles all mentor tiers. When an admin approves a `COMPANY_MENTOR`: +> +> 1. `UserMentor.status` → `APPROVED` +> 2. `UserRoleLink` (Mentor role) is created/updated for the user +> 3. A `UserOrganizationLink` is auto-created (or marked `verified = true`) linking the mentor user to the company's Organisation + +### `PATCH /api/v1/dashboard/mentor/verify//` + +**Auth:** JWT · `Admin` role required + +**Request body — Approve** + +```json +{ + "status": "APPROVED" +} +``` + +**Request body — Reject** + +```json +{ + "status": "REJECTED", + "verification_note": "Insufficient detail in expertise section." +} +``` + +| Field | Type | Values | +|---|---|---| +| `status` | string | `APPROVED` or `REJECTED` | +| `verification_note` | string | Required when `status` is `REJECTED` | + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Mentor status updated to APPROVED successfully."] }, + "response": {} +} +``` + +**Side-effects on approval (`COMPANY_MENTOR`)** + +| Side-effect | Table | Result | +|---|---|---| +| Mentor role granted | `user_role_link` | Role `Mentor` linked to user (`verified = true`) | +| Org link created/verified | `user_organization_link` | `user → company_org`, `verified = true` | + +--- + +## 3. Company Management (Extended) + +> Endpoints below resolve the company via `_get_company_for_user()` — accessible to: +> - the **verified company creator** (`company_user_id`), OR +> - an **approved `COMPANY_MENTOR`** for that company. +> +> No explicit `Company` role is required for these endpoints; JWT authentication plus company resolution is sufficient. +> +> **Exception:** mentor nomination/list (§1) still requires the `Company` role (creator only). + +--- + +### 3.1 Company Profile + +#### `GET /api/v1/dashboard/company/profile/` + +Retrieve the company profile. + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Success response `200`** + +Returns the full `CompanyDetailSerializer` shape (all model fields). See [Dashboard_Company.md §3 profile GET](./Dashboard_Company.md#3-profile) for the complete field list. + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "id": "cmp-uuid", + "company_user": "user-uuid", + "company_user_name": "Jane Doe", + "company_user_email": "jane@example.com", + "name": "Acme Corp", + "slug": "acme-corp", + "status": "verified", + "logo": "https://cdn.example.com/logo.png", + "description": "We build products.", + "short_pitch": "The best product company.", + "industry_sector": "Technology", + "website_link": "https://acme.com", + "email": "hr@acme.com", + "location": "Bangalore", + "company_size": "51-200", + "linkedin_url": "https://linkedin.com/company/acme", + "founded_year": 2015, + "remote_policy": "Hybrid", + "culture_text": "We value innovation.", + "tech_stack": ["Django", "React", "PostgreSQL"], + "perks": ["Health insurance", "Remote-friendly"], + "testimonials": [], + "gallery": [] + } +} +``` + +#### `PATCH /api/v1/dashboard/company/profile/` + +Update the company profile (partial update). + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Request body** *(all fields optional — same writable fields as company registration)* + +```json +{ + "description": "Updated description.", + "short_pitch": "Redefining product development.", + "remote_policy": "Remote-first", + "tech_stack": ["Django", "React", "PostgreSQL", "Redis"] +} +``` + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Company profile updated successfully."] }, + "response": { "...updated profile fields..." } +} +``` + +--- + +### 3.2 Company Jobs + +#### `POST /api/v1/dashboard/company/jobs/` + +Post a new job or gig. + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Request body** + +```json +{ + "title": "Backend Engineer", + "experience": "1-3 years", + "job_description": "Build and maintain REST APIs.", + "location": "Bangalore", + "salary_range": "6-10 LPA", + "job_type": "Full-Time", + "status": "Active", + "duration_value": null, + "duration_unit": null, + "hourly_rate": null, + "deliverables": null, + "stipend": null, + "certificate_provided": null, + "rules": [ + { "rule_type": "min_karma", "rule_value": "1000" } + ] +} +``` + +| Field | Notes | +|-------|-------| +| `job_type` | `Hybrid`, `Full-Time`, `Remote`, `Part-Time`, `Internship`, `Gig` | +| `status` | `Draft`, `Active`, `Closed`, `Expired` | +| `duration_unit` | `days`, `weeks`, `months` (for Gig / Internship) | +| `certificate_provided` | `Yes` or `No` | +| `rules` | Optional; `rule_type`: `min_karma`, `max_karma`, `min_level`, `max_level`, etc. | + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Job posted successfully."] }, + "response": { + "id": "job-uuid", + "title": "Backend Engineer", + "experience": "1-3 years", + "job_description": "Build and maintain REST APIs.", + "location": "Bangalore", + "salary_range": "6-10 LPA", + "job_type": "Full-Time", + "status": "Active", + "duration_value": null, + "duration_unit": null, + "hourly_rate": null, + "deliverables": null, + "stipend": null, + "certificate_provided": null, + "rules": [ + { "id": "rule-uuid", "rule_type": "min_karma", "rule_value": "1000" } + ] + } +} +``` + +--- + +#### `GET /api/v1/dashboard/company/jobs/` + +List all jobs for the authenticated company. + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Query params** + +| Param | Type | Description | +|---|---|---| +| `search` | string | Search by `title`, `location`, `job_type` | +| `sortBy` | string | `title` or `created_at` (prefix `-` for descending) | +| `pageIndex`, `perPage` | int | Pagination | + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "job-uuid", + "company_name": "Acme Corp", + "company_logo": "https://cdn.example.com/logo.png", + "title": "Backend Engineer", + "experience": "1-3 years", + "job_description": "Build and maintain REST APIs.", + "location": "Bangalore", + "salary_range": "6-10 LPA", + "job_type": "Full-Time", + "status": "Active", + "duration_value": null, + "duration_unit": null, + "hourly_rate": null, + "deliverables": null, + "stipend": null, + "certificate_provided": null, + "rules": [], + "created_at": "2026-06-01T10:00:00Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +#### `GET /api/v1/dashboard/company/jobs//` + +Retrieve details of a specific job. Returns a single job object in `response` (same fields as list item). + +**Auth:** JWT · verified company creator or approved Company Mentor + +#### `PATCH /api/v1/dashboard/company/jobs//` + +Update a specific job (partial). Replacing `rules` deletes existing rules and recreates them. + +**Auth:** JWT · verified company creator or approved Company Mentor + +#### `DELETE /api/v1/dashboard/company/jobs//` + +Soft-delete a specific job (`is_deleted = true`). + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Job deleted successfully."] }, + "response": {} +} +``` + +--- + +### 3.3 Job Applications + +#### `GET /api/v1/dashboard/company/jobs//applications/` + +List all applicants for a specific job. + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Query params:** `pageIndex`, `perPage`, `search`, `sortBy` (`applied_at`, `status`) + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "app-uuid", + "job": "job-uuid", + "applicant_name": "Priya K", + "applicant_email": "priya@example.com", + "resume_link": "https://cdn.example.com/resume.pdf", + "cover_letter": "I am excited to apply.", + "status": "Pending", + "rejection_reason": null, + "applied_at": "2026-06-02T08:30:00Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +**Application status values:** `Pending`, `In-Review`, `Shortlisted`, `Interview`, `Selected`, `Rejected` + +#### `PATCH /api/v1/dashboard/company/applications//status/` + +Update the status of a job application. + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Request body** + +```json +{ + "status": "Shortlisted", + "rejection_reason": null +} +``` + +**Reject example:** + +```json +{ + "status": "Rejected", + "rejection_reason": "Profile does not match required experience." +} +``` + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Application status updated successfully."] }, + "response": { + "id": "app-uuid", + "job": "job-uuid", + "applicant_name": "Priya K", + "applicant_email": "priya@example.com", + "resume_link": "https://cdn.example.com/resume.pdf", + "cover_letter": "I am excited to apply.", + "status": "Shortlisted", + "rejection_reason": null, + "applied_at": "2026-06-02T08:30:00Z" + } +} +``` + +--- + +### 3.4 MuLearner Directory + +#### `GET /api/v1/dashboard/company/mulearners/` + +Browse the directory of MuLearners. Only users with `is_public = true` in user settings are returned. + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Query params** + +| Param | Type | Description | +|---|---|---| +| `min_karma` | int | Minimum karma score | +| `max_karma` | int | Maximum karma score | +| `level` | int | Level order number | +| `college` | string | College name (partial match) | +| `department` | string | Department (partial match) | +| `graduation_year` | string | Graduation year | +| `ig` | string | Interest Group name (partial match) | +| `skill` | string | Skill UUID | +| `achievement` | string | Achievement UUID | +| `task` | string | Task UUID (users with karma log for task) | +| `pageIndex`, `perPage`, `search`, `sortBy` | — | Search `full_name`, `muid`, `email`; sort `full_name`, `created_at`, `karma` | + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "u-uuid", + "full_name": "Arjun Nair", + "muid": "arjun-nair@mulearn", + "email": "arjun@example.com", + "karma": 4200, + "level": 4, + "college": "CUSAT", + "department": "Computer Science", + "graduation_year": 2026 + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +### 3.5 Gig Analytics + +#### `GET /api/v1/dashboard/company/analytics/gigs/` + +Retrieve analytics for **Gig**-type jobs posted by the company. + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "total_gigs_posted": 12, + "active_gigs": 5, + "closed_gigs": 7, + "average_hourly_rate": 850.0, + "application_funnel": { + "Total": 148, + "Pending": 40, + "In-Review": 30, + "Shortlisted": 22, + "Interview": 18, + "Selected": 8, + "Rejected": 30 + }, + "conversion_rate": "5.41%" + } +} +``` + +--- + +### 3.6 Task Management + +> Company creators and Company Mentors can submit tasks. Tasks start in `pending` state and go active only after admin approval. +> +> **Important:** Tasks are scoped to **`requested_by = current user`**, not to the company as a whole. Each mentor/creator only sees and manages tasks they personally submitted. +> +> Full task field reference: [Dashboard_Company_Tasks.md](./Dashboard_Company_Tasks.md) + +#### `GET /api/v1/dashboard/company/tasks/` + +List tasks submitted by the **authenticated user** (`requested_by_id = current user`). + +**Auth:** JWT (any authenticated user; returns empty list if none submitted) + +**Query params** + +| Param | Description | +|---|---| +| `approval_status` | Filter: `pending` / `approved` / `rejected` | +| `pageIndex`, `perPage`, `search`, `sortBy` | Search `hashtag`, `title`, `description`, `karma`, `ig__name`, `type__title`, `approval_status` | + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "task-uuid", + "hashtag": "#acme-onboarding-2026", + "discord_link": null, + "title": "Acme Onboarding Challenge", + "description": "Complete the onboarding modules provided by Acme Corp.", + "karma": 200, + "channel": null, + "type": "Learning", + "active": false, + "variable_karma": false, + "usage_count": 1, + "level": "Level 4", + "org": null, + "ig": null, + "event": null, + "bonus_karma": null, + "bonus_time": null, + "approval_status": "pending", + "rejection_reason": null, + "reviewed_at": null, + "requested_by_name": "Jane Smith", + "requested_at": "2026-06-01T00:00:00Z", + "skills": [ + { "id": "skill-uuid-1", "name": "Python", "code": "python" } + ], + "created_at": "2026-06-01T00:00:00Z", + "updated_at": "2026-06-01T00:00:00Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +#### `POST /api/v1/dashboard/company/tasks/` + +Submit a new task for admin approval. Requires a verified company profile (creator or approved Company Mentor). + +**Auth:** JWT · verified company creator or approved Company Mentor + +**Request body** + +```json +{ + "hashtag": "#acme-onboarding-2026", + "title": "Acme Onboarding Challenge", + "description": "Complete the onboarding modules provided by Acme Corp.", + "karma": 200, + "usage_count": 1, + "type": "type-uuid", + "level": "level-uuid", + "skill_ids": ["skill-uuid-1", "skill-uuid-2"] +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `hashtag` | Yes | Globally unique | +| `title` | Yes | Max 75 chars | +| `karma` | Yes | Integer | +| `type` | Yes | `TaskType` UUID | +| `description`, `usage_count`, `level`, `skill_ids` | No | `ig` / `channel` are **not** part of the company task create serializer | + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task submitted for approval."] }, + "response": {} +} +``` + +#### `GET /api/v1/dashboard/company/tasks//` + +Retrieve details of a task where `requested_by` is the current user. Requires verified company profile. + +#### `PUT /api/v1/dashboard/company/tasks//` + +Edit a task (resets `approval_status` to `pending` and `active` to `false` for re-approval). + +**Success message:** `"Task updated and re-submitted for approval."` + +#### `DELETE /api/v1/dashboard/company/tasks//` + +Delete a task. Only `pending` tasks can be deleted. + +**Success message:** `"Task deleted successfully."` + +--- + +## 4. Mentor Sessions (Extended) + +### 4.1 Create Session + +#### `POST /api/v1/dashboard/mentor/session/create/` + +Create a new mentorship session (starts in `PENDING_APPROVAL`). The endpoint **auto-detects** mentor type: + +- **Company Mentor** (approved `COMPANY_MENTOR`) → `session_type = company_session`, `entity_id = company org UUID` (auto-resolved; do **not** pass `ig`) +- **IG Mentor** → `session_type = ig_session`, `entity_id = ig` (caller must pass `ig`; must be assigned as mentor for that IG) + +> If the caller is an approved Company Mentor, the company path takes precedence even if `ig` is supplied. + +**Auth:** JWT · `Mentor` role required + +**Request body — IG Mentor** + +```json +{ + "ig": "ig-uuid", + "title": "Python Basics for Beginners", + "description": "Covering core Python concepts.", + "mode": "ONLINE", + "starts_at": "2026-06-10T10:00:00Z", + "ends_at": "2026-06-10T11:30:00Z", + "meeting_link": "https://meet.google.com/abc-defg-hij", + "venue": null, + "max_participants": 30 +} +``` + +**Request body — Company Mentor** + +```json +{ + "title": "Acme Developer Mentoring — Sprint 1", + "description": "Weekly sync for onboarding batch.", + "mode": "ONLINE", + "starts_at": "2026-06-11T15:00:00Z", + "ends_at": "2026-06-11T16:00:00Z", + "meeting_link": "https://meet.google.com/xyz-1234-abc", + "venue": null, + "max_participants": 20 +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `ig` | IG mentors only | Interest Group UUID | +| `title` | Yes | Max 150 chars | +| `description` | No | | +| `mode` | Yes | `ONLINE`, `OFFLINE`, or `HYBRID` (uppercase) | +| `starts_at` | Yes | ISO 8601 datetime | +| `ends_at` | Yes | Must be after `starts_at` | +| `meeting_link` | No | For online/hybrid | +| `venue` | No | For offline/hybrid | +| `max_participants` | No | Cap on joins | + +**Success response `200` — Company Mentor** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Session created successfully and is pending approval."] }, + "response": { + "entity_id": "org-uuid-of-company", + "session_type": "company_session", + "title": "Acme Developer Mentoring — Sprint 1", + "description": "Weekly sync for onboarding batch.", + "mode": "ONLINE", + "starts_at": "2026-06-11T15:00:00Z", + "ends_at": "2026-06-11T16:00:00Z", + "meeting_link": "https://meet.google.com/xyz-1234-abc", + "venue": null, + "max_participants": 20 + } +} +``` + +**Success response `200` — IG Mentor** + +Same shape, but `entity_id` is the IG UUID and `session_type` is `ig_session`. The response does **not** include a separate `ig` field. + +--- + +### 4.2 Available Sessions (for Learners) + +#### `GET /api/v1/dashboard/mentor/session/available/` + +List `SCHEDULED` sessions available to the authenticated user: + +- IG sessions for Interest Groups the user belongs to +- Company sessions for company orgs the user is linked to via `UserOrganizationLink` + +**Auth:** JWT (any authenticated user) + +**Query params** + +| Param | Description | +|---|---| +| `search` | Search by `title` or `description` | +| `sortBy` | `starts_at` or `created_at` | +| `pageIndex`, `perPage` | Pagination | + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "sess-uuid-1", + "entity_id": "ig-uuid", + "entity_name": "Python", + "session_type": "ig_session", + "title": "Python Basics for Beginners", + "mode": "ONLINE", + "starts_at": "2026-06-10T10:00:00Z", + "ends_at": "2026-06-10T11:30:00Z", + "status": "SCHEDULED", + "created_by_id": "user-uuid", + "created_by_name": "Mentor One", + "created_at": "2026-06-01T10:00:00Z", + "max_participants": 30 + }, + { + "id": "sess-uuid-2", + "entity_id": "org-uuid-of-acme", + "entity_name": "Acme Corp", + "session_type": "company_session", + "title": "Acme Developer Mentoring — Sprint 1", + "mode": "ONLINE", + "starts_at": "2026-06-11T15:00:00Z", + "ends_at": "2026-06-11T16:00:00Z", + "status": "SCHEDULED", + "created_by_id": "user-uuid-2", + "created_by_name": "Jane Smith", + "created_at": "2026-06-02T10:00:00Z", + "max_participants": 20 + } + ], + "pagination": { + "count": 2, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +## 5. Calendar + +> All session calendar endpoints return sessions grouped into `upcoming`, `ongoing`, and `completed` buckets. +> Use the `month` query param (format `YYYY-MM`) to filter by month. +> +> Session items use `MentorshipSessionCalendarSerializer` — they include `mentor_name` and `mentee_count`, **not** a `participants` array. + +--- + +### 5.1 Company Session Calendar + +#### `GET /api/v1/calendar/company//sessions/` + +Calendar view of all mentorship sessions for a specific company org. + +**Auth:** None (public) + +**Path param:** `company_org_id` — UUID of the company's `Organisation` record (`org_type = Company`) + +**Query params** + +| Param | Type | Description | +|---|---|---| +| `month` | string | `YYYY-MM` (e.g. `2026-06`) | +| `status` | string | `SCHEDULED`, `COMPLETED`, or `CANCELLED` | + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "upcoming": [ + { + "id": "sess-uuid", + "title": "Acme Developer Mentoring — Sprint 1", + "description": "Weekly sync for onboarding batch.", + "mode": "ONLINE", + "starts_at": "2026-06-11T15:00:00Z", + "ends_at": "2026-06-11T16:00:00Z", + "status": "SCHEDULED", + "meeting_link": "https://meet.google.com/xyz-1234-abc", + "venue": null, + "mentor_name": "Jane Smith", + "mentee_count": 0 + } + ], + "ongoing": [], + "completed": [ + { + "id": "sess-uuid-old", + "title": "Acme Kick-off Session", + "description": "Company onboarding kick-off.", + "mode": "OFFLINE", + "starts_at": "2026-06-01T10:00:00Z", + "ends_at": "2026-06-01T11:00:00Z", + "status": "COMPLETED", + "meeting_link": null, + "venue": "Acme HQ, Bangalore", + "mentor_name": "Jane Smith", + "mentee_count": 12 + } + ] + } +} +``` + +--- + +### 5.2 IG Mentor Session Calendar + +#### `GET /api/v1/calendar/ig-mentor//sessions/` + +Calendar view of mentorship sessions for a specific Interest Group. + +**Auth:** None (public) + +**Query params:** `month` (YYYY-MM), `status` (`SCHEDULED`, `COMPLETED`, `CANCELLED`) + +**Success response `200`:** Same bucket shape as [§5.1](#51-company-session-calendar). + +--- + +### 5.3 Campus Mentor Session Calendar + +#### `GET /api/v1/calendar/campus-mentor//sessions/` + +Calendar view of mentorship sessions for a specific campus (college org). + +**Auth:** None (public) + +**Query params:** `month` (YYYY-MM), `status` (`SCHEDULED`, `COMPLETED`, `CANCELLED`) + +**Success response `200`:** Same bucket shape as [§5.1](#51-company-session-calendar). + +--- + +## 6. Leaderboard + +### 6.1 IG Mentor Leaderboard + +#### `GET /api/v1/leaderboard/ig-mentor//` + +Ranked list of IG Mentors for a specific Interest Group. + +Ranked by: +1. Number of **completed sessions** in that IG (descending) +2. Total **karma** as tiebreaker (descending) + +**Auth:** None (public) + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": [ + { + "rank": 1, + "mentor_id": "u-uuid", + "mentor_name": "Rahul Menon", + "profile_pic": "https://cdn.example.com/rahul.png", + "total_karma": 8500, + "completed_sessions": 14, + "ig_name": "Python" + }, + { + "rank": 2, + "mentor_id": "u-uuid-2", + "mentor_name": "Sneha Das", + "profile_pic": null, + "total_karma": 7200, + "completed_sessions": 10, + "ig_name": "Python" + } + ] +} +``` + +> **Note:** `mentor_id` is the **user's UUID** (`user.id`), not the `UserMentor.id`. There is no `muid` or separate `user_id` field in the response. + +--- + +### 6.2 Campus Mentor Leaderboard + +#### `GET /api/v1/leaderboard/campus-mentor//` + +Ranked list of Campus Mentors for a specific campus (college org). + +Ranked by: +1. Number of **completed campus sessions** (descending) +2. Total **karma** as tiebreaker (descending) + +**Auth:** None (public) + +**Success response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": [ + { + "rank": 1, + "mentor_id": "u-uuid", + "mentor_name": "Divya Krishnan", + "profile_pic": "https://cdn.example.com/divya.png", + "total_karma": 6400, + "completed_sessions": 9, + "campus_name": "CUSAT" + }, + { + "rank": 2, + "mentor_id": "u-uuid-2", + "mentor_name": "Arun Pillai", + "profile_pic": null, + "total_karma": 5100, + "completed_sessions": 6, + "campus_name": "CUSAT" + } + ] +} +``` + +--- + +## 7. Event Management + +Company Mentors can manage (CRUD) events for their company via the `/api/v1/dashboard/events/` endpoints. The `organiser_type` MUST be set to `Company` (`Event.OrganiserType.COMPANY`). + +### 7.1 View Company Events + +#### `GET /api/v1/dashboard/events/company//` +List all published/ongoing events for the company. (Public feed) + +#### `GET /api/v1/dashboard/events/manage/` +List all events the Company Mentor has permission to manage (including drafts and pending). + +#### `GET /api/v1/dashboard/events/manage//` +Get full details of a specific event, including its edit history. + +### 7.2 Create Event + +#### `POST /api/v1/dashboard/events/manage/` +Create a new company event. + +**Auth:** JWT · verified `COMPANY_MENTOR` + +**Request body** +```json +{ + "title": "Company Tech Talk", + "description": "Discussing the latest tech stack.", + "start_datetime": "2026-07-01T10:00:00Z", + "end_datetime": "2026-07-01T12:00:00Z", + "venue_type": "ONLINE", + "organiser_type": "Company" +} +``` + +**Success response `200`** +Returns the created event object. + +### 7.3 Update Event + +#### `PUT /api/v1/dashboard/events/manage//` +Full update of an event. + +#### `PATCH /api/v1/dashboard/events/manage//` +Partial update of an event. *Note: You cannot change `organiser_type` to anything other than `Company`.* + +### 7.4 Cancel Event + +#### `DELETE /api/v1/dashboard/events/manage//` +Soft cancel an event (status becomes `CANCELLED`). + +### 7.5 Publish Event + +#### `POST /api/v1/dashboard/events/manage//publish/` +Transition a `DRAFT` event into the approval pipeline (`PENDING_APPROVAL`). + +--- + +## DB Migration Requirement + +> [!IMPORTANT] +> Before deploying, add `company_session` to the `session_type` ENUM column on `mentorship_session` (the model is `managed = False`, so Django migrations won't auto-generate this): + +```sql +ALTER TABLE mentorship_session + MODIFY COLUMN session_type + ENUM('ig_session', 'campus_session', 'company_session') + NOT NULL DEFAULT 'ig_session'; +``` + +Apply this SQL manually on the target database before enabling company mentor sessions in production. + +--- + +## Access Control Summary + +| Role | Nominate | List nominations | Profile / Jobs / Analytics / MuLearners | Tasks | Sessions (create) | Calendar / Leaderboard | +|---|---|---|---|---|---|---| +| Company Creator (`Company` role) | ✅ | ✅ | ✅ | ✅ (own tasks) | ✅ (as `Mentor` if approved) | Public | +| Approved Company Mentor | ❌ | ❌ | ✅ | ✅ (own tasks only) | ✅ (`company_session`) | Public | +| IG Mentor | — | — | — | ✅ (own IG tasks) | ✅ (`ig_session`) | Public | +| Campus Mentor | — | — | — | — | ✅ (`campus_session`) | Public | +| Admin | — | — | — | ✅ (approve via `/dashboard/task/`) | ✅ (verify sessions) | Public | +| Any Authenticated User | — | — | apply to jobs | list own submitted tasks | join / view available | Public | +| Public (no auth) | — | — | browse public company profile & jobs | — | calendar | leaderboard | + +--- + +## Corrections applied vs. earlier draft + +| Area | Issue in draft | Actual behaviour | +|------|----------------|------------------| +| Response envelope | Flat `"message": "string"` | `hasError` + `message.general[]` array | +| Pagination | `page`, `per_page`, `next`, `previous` | `pageIndex`, `perPage`, `totalPages`, `isNext`, `isPrev`, `nextPage` | +| Nomination / list auth | "Company Creator or Mentor" | **`Company` role only** (verified creator) | +| Jobs POST body | `description`, `experience_min`, `skills_required`, `mode` | `job_description`, `experience`, `salary_range`, `job_type`, optional `rules` | +| Applications | `user_name`, `resume_url` | `applicant_name`, `applicant_email`, `resume_link`, `job` | +| MuLearners | `igs[]`, `level` as string | `level` as integer; `email`, `department`, `graduation_year`; no `igs` | +| Tasks | Company-wide list; `channel`, `ig` on create | Per-user (`requested_by`); create uses `type`/`level` UUIDs + `skill_ids` | +| Session `mode` | `"Online"` | `"ONLINE"`, `"OFFLINE"`, `"HYBRID"` | +| Session create response | Mixed `ig` field for company | `entity_id` + `session_type`; no `ig` key | +| Available sessions | Missing `created_at`, `created_by_id` | Full `SessionListSerializer` fields | +| Calendar items | `participants[]`, `session_type` | `mentor_name`, `mentee_count`, `description`, `meeting_link`, `venue` | +| Leaderboard | `mentor_id` = UserMentor id; `full_name`, `muid` | `mentor_id` = **user id**; `mentor_name`, `profile_pic` | diff --git a/api_docs/Dashboard_Company_Tasks.md b/api_docs/Dashboard_Company_Tasks.md new file mode 100644 index 000000000..627bc23c9 --- /dev/null +++ b/api_docs/Dashboard_Company_Tasks.md @@ -0,0 +1,522 @@ +# Dashboard — Company Tasks & Admin Approval + +**Company base path:** `/api/v1/dashboard/company/` +**Admin approval base path:** `/api/v1/dashboard/task/` +**Source:** `api/dashboard/company/task_views.py`, `api/dashboard/company/task_serializers.py`, `api/dashboard/task/dash_task_view.py` +**OpenAPI tags:** `Dashboard - Company Task`, `Dashboard - Task` + +Related docs: [Dashboard_Company.md](./Dashboard_Company.md), [Dashboard_Mentor_Tasks.md](./Dashboard_Mentor_Tasks.md) (shared admin approval flow) + +--- + +## Table of Contents + +| # | Endpoint | Method(s) | Role | +|---|----------|-----------|------| +| 1 | [`tasks/`](#1-tasks) | `GET`, `POST` | Company | +| 2 | [`tasks//`](#2-taskstask_id) | `GET`, `PUT`, `DELETE` | Company | +| 3 | [`task/pending/`](#3-admin-list-pending-tasks) | `GET` | Admin | +| 4 | [`task//review/`](#4-admin-approve-or-reject-task) | `PATCH` | Admin | + +--- + +## Overview + +### Response envelope + +**Success:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Human-readable success message"] }, + "response": {} +} +``` + +**Failure:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Error summary"], + "field_name": ["Validation detail"] + }, + "response": {} +} +``` + +### Authentication + +```http +Authorization: Bearer +``` + +Required on all endpoints below. + +### Pagination & search (`tasks/` GET) + +| Query param | Default | Description | +|-------------|---------|-------------| +| `pageIndex` | `1` | Page number | +| `perPage` | `10` | Items per page | +| `search` | — | Searches `hashtag`, `title`, `description`, `karma`, `ig__name`, `type__title`, `approval_status` | +| `sortBy` | — | e.g. `title`, `-created_at`, `approval_status`, `karma` | + +**Paginated response shape:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [], + "pagination": { + "count": 10, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +### Prerequisites + +| Action | Requirement | +|--------|-------------| +| List / detail / update / delete tasks | JWT role `Company`; tasks must have `requested_by` = current user | +| **Create** task | Same as above **plus** a **verified** company profile (`company.status = verified` linked to `company_user_id`) | + +### Task approval lifecycle + +| `approval_status` | `active` | Meaning | +|-------------------|----------|---------| +| `pending` | `false` | Submitted by company; awaiting admin review | +| `approved` | `true` | Admin approved; task is live for learners | +| `rejected` | `false` | Admin rejected; see `rejection_reason` | + +- On **create**, tasks are saved as `pending` / `active=false`. +- On **edit** (`PUT`), status resets to `pending` and `active=false` (re-approval required). +- Only **pending** tasks can be **deleted**. + +Unlike mentor tasks, company tasks do **not** require an Interest Group (`ig` is optional and not part of the create/update serializer fields). + +--- + +## 1. `tasks/` + +### List company-submitted tasks + +**`GET /api/v1/dashboard/company/tasks/`** + +Lists all tasks where `requested_by` is the authenticated company user. + +**Roles:** `Company` + +**Query params:** + +| Param | Required | Description | +|-------|----------|-------------| +| `approval_status` | No | Filter: `pending`, `approved`, or `rejected` | +| `pageIndex`, `perPage`, `search`, `sortBy` | No | Pagination and sorting | + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "task-uuid-001", + "hashtag": "#acme-api-challenge", + "discord_link": null, + "title": "Build a Rate Limiter", + "description": "Implement a token-bucket rate limiter in your language of choice.", + "karma": 500, + "channel": null, + "type": "Implementation", + "active": false, + "variable_karma": false, + "usage_count": 1, + "level": "Level 4", + "org": null, + "ig": null, + "event": null, + "bonus_karma": null, + "bonus_time": null, + "approval_status": "pending", + "rejection_reason": null, + "reviewed_at": null, + "requested_by_name": "Acme Corp HR", + "requested_at": "2026-05-20T09:00:00Z", + "skills": [ + { + "id": "skill-uuid-go", + "name": "Go", + "code": "go" + } + ], + "created_at": "2026-05-20T09:00:00Z", + "updated_at": "2026-05-20T09:00:00Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +### Create a task (submit for approval) + +**`POST /api/v1/dashboard/company/tasks/`** + +Creates a task for admin approval. Requires a **verified** company profile. + +**Roles:** `Company` + +**Request body:** + +```json +{ + "hashtag": "#acme-api-challenge", + "title": "Build a Rate Limiter", + "karma": 500, + "usage_count": 1, + "description": "Implement a token-bucket rate limiter with tests and a short write-up.", + "type": "task-type-uuid", + "level": "level-uuid", + "skill_ids": [ + "skill-uuid-go", + "skill-uuid-system-design" + ] +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `hashtag` | Yes | Globally unique | +| `title` | Yes | Max 75 chars | +| `karma` | Yes | Integer | +| `usage_count` | No | Default `1` on model | +| `description` | No | Text | +| `type` | Yes | `TaskType` UUID | +| `level` | No | `Level` UUID | +| `skill_ids` | No | Array of active skill UUIDs | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task submitted for approval."] }, + "response": {} +} +``` + +**Error — unverified company (403):** + +```json +{ + "hasError": true, + "statusCode": 403, + "message": { + "general": ["You must have a verified company profile to submit tasks."] + }, + "response": {} +} +``` + +**Validation error example:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "hashtag": ["A task with this hashtag already exists."] + }, + "response": {} +} +``` + +--- + +## 2. `tasks//` + +### Get task detail + +**`GET /api/v1/dashboard/company/tasks//`** + +**Roles:** `Company` (only tasks where `requested_by` is the current user) + +**Request body:** None + +**Success response:** Single task object (same shape as one item in the list `data` array above). + +**Error:** `404` — Task not found or not owned by this company user. + +--- + +### Update task (re-submit for approval) + +**`PUT /api/v1/dashboard/company/tasks//`** + +Partial updates allowed. After save, task is reset to `pending`, `active=false`, and review fields cleared. + +**Roles:** `Company` + +**Request body (partial example):** + +```json +{ + "title": "Build a Rate Limiter (v2)", + "karma": 600, + "description": "Added distributed-systems requirements.", + "skill_ids": ["skill-uuid-go"] +} +``` + +Writable fields: `hashtag`, `title`, `karma`, `usage_count`, `description`, `type`, `level`, `skill_ids`. + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task updated and re-submitted for approval."] }, + "response": {} +} +``` + +--- + +### Delete task + +**`DELETE /api/v1/dashboard/company/tasks//`** + +**Roles:** `Company` + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task deleted successfully."] }, + "response": {} +} +``` + +**Error:** Only `pending` tasks can be deleted. + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Cannot delete a task with status 'approved'. Only pending tasks can be deleted."] + }, + "response": {} +} +``` + +--- + +# Admin — Company task verification + +Admins review company-submitted tasks using the shared task approval API (same as mentor tasks). + +**Base path:** `/api/v1/dashboard/task/` + +--- + +## 3. Admin — list pending tasks + +**`GET /api/v1/dashboard/task/pending/`** + +**Roles:** `Admin` + +**Example — company tasks awaiting review:** + +```http +GET /api/v1/dashboard/task/pending/?approval_status=pending&role=company&pageIndex=1&perPage=10 +``` + +Optional: `?company_name=Acme` to filter by company profile name. + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Tasks fetched successfully."] }, + "response": { + "tasks": [ + { + "id": "task-uuid-001", + "title": "Build a Rate Limiter", + "hashtag": "#acme-api-challenge", + "description": "Implement a token-bucket rate limiter with tests.", + "karma": 500, + "approval_status": "pending", + "ig": null, + "type": { + "id": "task-type-uuid", + "title": "Implementation" + }, + "company_name": "Acme Technologies", + "requested_by": { + "id": "user-uuid-company", + "full_name": "Acme Corp HR" + }, + "requested_at": "2026-05-20T09:00:00+00:00", + "created_at": "2026-05-20T09:00:00+00:00" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +## 4. Admin — approve or reject task + +**`PATCH /api/v1/dashboard/task//review/`** + +**Roles:** `Admin` — only tasks with `approval_status=pending`. + +### Approve + +**Request body:** + +```json +{ + "action": "approve" +} +``` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task approved and is now live."] }, + "response": { + "task_id": "task-uuid-001", + "approval_status": "approved", + "active": true, + "rejection_reason": null, + "reviewed_by": "admin-user-uuid", + "reviewed_at": "2026-05-21T10:30:00+00:00" + } +} +``` + +### Reject + +**Request body:** + +```json +{ + "action": "reject", + "reason": "Task description does not meet platform content guidelines. Please add learning outcomes and resubmit." +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `action` | Yes | `approve` or `reject` | +| `reason` | Yes when `action=reject` | Stored in `rejection_reason` | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task rejected."] }, + "response": { + "task_id": "task-uuid-001", + "approval_status": "rejected", + "active": false, + "rejection_reason": "Task description does not meet platform content guidelines. Please add learning outcomes and resubmit.", + "reviewed_by": "admin-user-uuid", + "reviewed_at": "2026-05-21T10:35:00+00:00" + } +} +``` + +--- + +## End-to-end flow + +```mermaid +sequenceDiagram + participant C as Company User + participant API as Company Task API + participant A as Admin + participant T as Task API (Admin) + + C->>API: POST tasks/ (verified company) + API-->>C: pending, active=false + A->>T: GET task/pending/?role=company + T-->>A: Pending company tasks + A->>T: PATCH task/{id}/review/ approve + T-->>A: approved, active=true + C->>API: GET tasks/?approval_status=approved + API-->>C: Live task in company list +``` + +--- + +## Company vs mentor tasks + +| Aspect | Company (`/company/tasks/`) | Mentor (`/mentor/tasks/`) | +|--------|----------------------------|---------------------------| +| Role | `Company` | `Mentor` | +| Create prerequisite | Verified `company` profile | Active IG mentor assignment | +| `ig` field | Not used on create/update | Required | +| Admin list filter | `?role=company` | `?role=mentor` | + +--- + +## Related endpoints + +| Action | Endpoint | +|--------|----------| +| Company registration / verification | `/api/v1/dashboard/company/register/`, `verify//` | +| Admin task dropdowns (`type`, `level`) | `/api/v1/dashboard/task/task-types/`, `level/` | +| Full admin task CRUD | `/api/v1/dashboard/task/` | diff --git a/api_docs/Dashboard_Coupon.md b/api_docs/Dashboard_Coupon.md new file mode 100644 index 000000000..d0dc58955 --- /dev/null +++ b/api_docs/Dashboard_Coupon.md @@ -0,0 +1,31 @@ +# Dashboard / Coupon + + +Base path: `/api/dashboard/coupon/` + + +## Endpoint: `verify-coupon/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Discord_Moderator.md b/api_docs/Dashboard_Discord_Moderator.md new file mode 100644 index 000000000..733d7c668 --- /dev/null +++ b/api_docs/Dashboard_Discord_Moderator.md @@ -0,0 +1,83 @@ +# Dashboard / Discord_Moderator + + +Base path: `/api/dashboard/discord_moderator/` + + +## Endpoint: `tasklist/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `pendingcounts/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `leaderboard/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_District.md b/api_docs/Dashboard_District.md new file mode 100644 index 000000000..f87ca3404 --- /dev/null +++ b/api_docs/Dashboard_District.md @@ -0,0 +1,320 @@ +# Dashboard — District API + +**Base path:** `/api/dashboard/district/` +**Authentication:** JWT Bearer Token required on all endpoints. + +--- + +## Table of Contents + +| # | Endpoint | Method | Auth / Role | +|---|----------|--------|-------------| +| 1 | [`district-details/`](#1-district-details) | `GET` | District Campus Lead | +| 2 | [`top-campus/`](#2-top-campus) | `GET` | District Campus Lead | +| 3 | [`student-level/`](#3-student-level) | `GET` | District Campus Lead | +| 4 | [`student-details/`](#4-student-details) | `GET` | District Campus Lead | +| 5 | [`student-details/csv/`](#5-student-detailscsv) | `GET` | District Campus Lead | +| 6 | [`college-details/`](#6-college-details) | `GET` | District Campus Lead | +| 7 | [`college-details/csv/`](#7-college-detailscsv) | `GET` | District Campus Lead | + +--- + +## 1. District Details + +**`GET /api/dashboard/district/district-details/`** + +Returns detailed information about the authenticated District Campus Lead's district, including the zone, lead name, karma stats, and membership counts. + +**Roles:** `District Campus Lead` + +**Request Body:** None + +**Query Params:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "district": "Ernakulam", + "zone": "Central Zone", + "rank": 2, + "district_lead": "Arjun Menon", + "karma": 128500, + "total_members": 1450, + "active_members": 620 + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `district` | string | Name of the district | +| `zone` | string | Name of the zone the district belongs to | +| `rank` | integer | District rank across all districts (by total karma) | +| `district_lead` | string \| null | Full name of the District Campus Lead | +| `karma` | integer | Total karma earned by all college members in the district | +| `total_members` | integer | Total number of college org links in the district | +| `active_members` | integer | Members with karma activity in the current month | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 2. Top Campus + +**`GET /api/dashboard/district/top-campus/`** + +Returns the top 3 campuses (colleges) in the district by total karma. + +**Roles:** `District Campus Lead` + +**Request Body:** None + +**Query Params:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": [ + { + "rank": 1, + "campus_code": "ECE001", + "karma": 45200 + }, + { + "rank": 2, + "campus_code": "SCT002", + "karma": 38100 + }, + { + "rank": 3, + "campus_code": "MES003", + "karma": 29800 + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `rank` | integer | Rank of the campus within the district | +| `campus_code` | string | Unique code of the college/campus | +| `karma` | integer | Total karma of all verified members in the campus | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 3. Student Level + +**`GET /api/dashboard/district/student-level/`** + +Returns the count of students at each karma level across all colleges in the district. + +**Roles:** `District Campus Lead` + +**Request Body:** None + +**Query Params:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": [ + { "level_order": 1, "students_count": 520 }, + { "level_order": 2, "students_count": 340 }, + { "level_order": 3, "students_count": 180 }, + { "level_order": 4, "students_count": 65 }, + { "level_order": 5, "students_count": 12 } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `level_order` | integer | The level number | +| `students_count` | integer | Number of distinct students at this level in the district | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 4. Student Details + +**`GET /api/dashboard/district/student-details/`** + +Returns a paginated list of all students across colleges in the district with their karma, level, and rank. + +**Roles:** `District Campus Lead` + +**Query Params:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `pageIndex` | integer | No | Page number (default: 1) | +| `perPage` | integer | No | Items per page (default: 20) | +| `sortBy` | string | No | Field to sort by (`full_name`, `muid`, `karma`, `level`) | +| `search` | string | No | Search by full name or level | + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "user_id": "a1b2c3d4-...", + "full_name": "Rahul Krishna", + "muid": "rahul-krishna@mulearn", + "karma": 3400, + "rank": 1, + "level": "Pathfinder" + } + ], + "pagination": { + "count": 1450, + "totalPages": 73, + "isFirst": true, + "isLast": false, + "nextPage": "/student-details/?pageIndex=2" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `user_id` | string (UUID) | Unique user ID | +| `full_name` | string | Student's full name | +| `muid` | string | muID of the student | +| `karma` | integer | Total karma points | +| `rank` | integer | Rank within the district | +| `level` | string | Current karma level name | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 5. Student Details CSV + +**`GET /api/dashboard/district/student-details/csv/`** + +Downloads the same student data as endpoint 4 as a CSV file (no pagination — exports all records). + +**Roles:** `District Campus Lead` + +**Request Body:** None + +**Response:** `text/csv` file download — `District Student Details.csv` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 6. College Details + +**`GET /api/dashboard/district/college-details/`** + +Returns a paginated list of all colleges in the district, including their campus code, level, and campus lead information. + +**Roles:** `District Campus Lead` + +**Query Params:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `pageIndex` | integer | No | Page number (default: 1) | +| `perPage` | integer | No | Items per page (default: 20) | +| `sortBy` | string | No | Field to sort by (`title`, `code`) | +| `search` | string | No | Search by college title or code | + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "82e6f245-59ea-4be4-a33d-35189a395000", + "title": "Example College of Engineering", + "code": "ECE001", + "level": 3, + "lead": "Arjun Menon", + "lead_number": "9876543210" + } + ], + "pagination": { + "count": 42, + "totalPages": 3, + "isFirst": true, + "isLast": false, + "nextPage": "/college-details/?pageIndex=2" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string (UUID) | Unique organization ID of the college | +| `title` | string | Name of the college | +| `code` | string | Campus code | +| `level` | integer \| null | College's campus level | +| `lead` | string \| null | Full name of the college's Campus Lead | +| `lead_number` | string \| null | Mobile number of the Campus Lead | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 7. College Details CSV + +**`GET /api/dashboard/district/college-details/csv/`** + +Downloads the same college data as endpoint 6 as a CSV file (no pagination — exports all records). + +**Roles:** `District Campus Lead` + +**Request Body:** None + +**Response:** `text/csv` file download — `District College Details.csv` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` diff --git a/api_docs/Dashboard_Dynamic_management.md b/api_docs/Dashboard_Dynamic_management.md new file mode 100644 index 000000000..8e93f8371 --- /dev/null +++ b/api_docs/Dashboard_Dynamic_management.md @@ -0,0 +1,277 @@ +# Dashboard / Dynamic_Management + + +Base path: `/api/dashboard/dynamic_management/` + + +## Endpoint: `dynamic-role/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dynamic-role/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dynamic-role/delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:type_id` +- Request body example (JSON): +```json +{ + "type_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dynamic-role/update//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:type_id` +- Request body example (JSON): +```json +{ + "type_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dynamic-user/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dynamic-user/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dynamic-user/delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:type_id` +- Request body example (JSON): +```json +{ + "type_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dynamic-user/update//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:type_id` +- Request body example (JSON): +```json +{ + "type_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `types/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `roles/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Error_log.md b/api_docs/Dashboard_Error_log.md new file mode 100644 index 000000000..0a3d675b6 --- /dev/null +++ b/api_docs/Dashboard_Error_log.md @@ -0,0 +1,173 @@ +# Dashboard / Error_Log + + +Base path: `/api/dashboard/error_log/` + + +## Endpoint: `graph/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `tab/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `patch//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:error_id` +- Request body example (JSON): +```json +{ + "error_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:log_name` +- Request body example (JSON): +```json +{ + "log_name": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `view//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:log_name` +- Request body example (JSON): +```json +{ + "log_name": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `clear//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:log_name` +- Request body example (JSON): +```json +{ + "log_name": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Events.md b/api_docs/Dashboard_Events.md new file mode 100644 index 000000000..3d8703a7e --- /dev/null +++ b/api_docs/Dashboard_Events.md @@ -0,0 +1,34 @@ +# Dashboard / Events + + +Base path: `/api/dashboard/events/` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:event_id` +- Request body example (JSON): +```json +{ + "event_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_IG.md b/api_docs/Dashboard_IG.md new file mode 100644 index 000000000..98d8f0aec --- /dev/null +++ b/api_docs/Dashboard_IG.md @@ -0,0 +1,115 @@ +# Dashboard / Ig + + +Base path: `/api/dashboard/ig/` + + +## Endpoint: `list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `csv/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:pk` +- Request body example (JSON): +```json +{ + "pk": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `get//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:pk` +- Request body example (JSON): +```json +{ + "pk": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Karma_Voucher.md b/api_docs/Dashboard_Karma_Voucher.md new file mode 100644 index 000000000..99a1ecde2 --- /dev/null +++ b/api_docs/Dashboard_Karma_Voucher.md @@ -0,0 +1,167 @@ +# Dashboard / Karma_Voucher + + +Base path: `/api/dashboard/karma_voucher/` + + +## Endpoint: `import/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `export/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `update//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:voucher_id` +- Request body example (JSON): +```json +{ + "voucher_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:voucher_id` +- Request body example (JSON): +```json +{ + "voucher_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `base-template/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_LC.md b/api_docs/Dashboard_LC.md new file mode 100644 index 000000000..e7f4aa02a --- /dev/null +++ b/api_docs/Dashboard_LC.md @@ -0,0 +1,950 @@ +# Dashboard / Lc + + +Base path: `/api/dashboard/lc/` + + +## Endpoint: `meets/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/list//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:is_user` +- Request body example (JSON): +```json +{ + "is_user": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/meet/create/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/meet/list/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/report//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/attendee-report//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/attendee-report///` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` + - `str:task_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "task_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/verify-list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/verify//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/attendees//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/interested//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/info//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meets/join//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_code_id` +- Request body example (JSON): +```json +{ + "meet_code_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `stats/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/details/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/schedule-meet/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/add-member/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `join//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/ig-progress/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/lead-transfer//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` + - `str:new_lead_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "new_lead_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/note/edit/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/user-accept-reject//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` + - `str:member_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "member_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-all//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_code` +- Request body example (JSON): +```json +{ + "circle_code": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-all/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-members//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `invite/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meet-record/list-all//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meet-record/edit//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `member/invite///` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` + - `str:muid` +- Request body example (JSON): +```json +{ + "circle_id": "", + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `member/invite/status////` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` + - `str:muid` + - `str:status` +- Request body example (JSON): +```json +{ + "circle_id": "", + "muid": "", + "status": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_LearningCircle.md b/api_docs/Dashboard_LearningCircle.md new file mode 100644 index 000000000..725b13772 --- /dev/null +++ b/api_docs/Dashboard_LearningCircle.md @@ -0,0 +1,525 @@ +# Dashboard / Learningcircle + + +Base path: `/api/dashboard/learningcircle/` + + +## Endpoint: `create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `info//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `members//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `edit//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/create//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/list-public/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/list//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:circle_id` +- Request body example (JSON): +```json +{ + "circle_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/edit//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/info//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/join//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/rsvp//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/leave//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/attendee-report//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/report//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:meet_id` +- Request body example (JSON): +```json +{ + "meet_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `meeting/report/export//` +- Method: `GET` +- Brief: Export a meeting's minutes and every attendee's report as a single CSV file. +- Path params: + - `str:meet_id` — UUID of the `CircleMeetingLog`. +- Permissions: meeting creator, circle lead, or circle creator. +- Response: `text/csv` file download (`Content-Disposition: attachment`). +- See `api_docs/LC_Report_Export.md` for the full CSV layout, curl/Postman recipes, and error cases. + diff --git a/api_docs/Dashboard_Location.md b/api_docs/Dashboard_Location.md new file mode 100644 index 000000000..e5c18927c --- /dev/null +++ b/api_docs/Dashboard_Location.md @@ -0,0 +1,303 @@ +# Dashboard / Location + + +Base path: `/api/dashboard/location/` + + +## Endpoint: `countries/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `countries/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `countries//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:country_id` +- Request body example (JSON): +```json +{ + "country_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `states/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `states/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `states//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:state_id` +- Request body example (JSON): +```json +{ + "state_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `zones/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `zones/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `zones//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:zone_id` +- Request body example (JSON): +```json +{ + "zone_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `districts/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `districts//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:district_id` +- Request body example (JSON): +```json +{ + "district_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Management.md b/api_docs/Dashboard_Management.md new file mode 100644 index 000000000..3d6153949 --- /dev/null +++ b/api_docs/Dashboard_Management.md @@ -0,0 +1,402 @@ +# Dashboard Management API Reference + +## Referral Management + +### List Referrals +**Endpoint:** `/api/v1/dashboard/referral/` +**Method:** `GET` +**Brief:** List all referrals. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Referrals Fetched Successfully" + ] + }, + "response": { + "data": [ + { + "id": "uuid", + "full_name": "Full Name", + "muid": "muid", + "karma": 100, + "level": "Level Name" + } + ], + "pagination": {} + } +} +``` + +### Send Referral Email +**Endpoint:** `/api/v1/dashboard/referral/send-referral/` +**Method:** `POST` +**Brief:** Send referral invites via email. +**Permissions:** Admin +**Request Body:** +```json +{ + "email": "user@example.com" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Referral Email Sent Successfully" + ] + }, + "response": {} +} +``` + +## Karma Voucher Management + +### List Vouchers +**Endpoint:** `/api/v1/dashboard/karma-voucher/` +**Method:** `GET` +**Brief:** List all karma vouchers. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Vouchers Fetched Successfully" + ] + }, + "response": { + "data": [ + { + "id": "uuid", + "code": "CODE", + "user": "User Name", + "task": "Task Title", + "karma": 100, + "month": "January", + "week": "W1", + "claimed": false, + "description": "desc", + "event": "event", + "created_by": "User", + "updated_by": "User", + "created_at": "date", + "updated_at": "date", + "muid": "muid" + } + ], + "pagination": {} + } +} +``` + +### Create Voucher +**Endpoint:** `/api/v1/dashboard/karma-voucher/` +**Method:** `POST` +**Brief:** Create a new karma voucher. +**Permissions:** Admin +**Request Body:** +```json +{ + "user": "muid_or_email", + "task": "task_uuid", + "karma": 100, + "month": "January", + "week": "W1" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Voucher Created Successfully" + ] + }, + "response": { + "code": "CODE", + "month": "January", + "week": "W1" + } +} +``` + +### Edit Voucher +**Endpoint:** `/api/v1/dashboard/karma-voucher//` +**Method:** `PUT` +**Brief:** Edit an existing voucher. +**Permissions:** Admin +**Request Body:** +```json +{ + "new_karma": 200 +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Voucher Updated Successfully" + ] + }, + "response": {} +} +``` + +### Delete Voucher +**Endpoint:** `/api/v1/dashboard/karma-voucher//` +**Method:** `DELETE` +**Brief:** Delete a voucher. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Voucher Deleted Successfully" + ] + }, + "response": {} +} +``` + +### Import Vouchers +**Endpoint:** `/api/v1/dashboard/karma-voucher/import/` +**Method:** `POST` +**Brief:** Import vouchers from CSV. +**Permissions:** Admin +**Form Data:** `voucher_data`: file +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Vouchers Imported Successfully" + ] + }, + "response": {} +} +``` + +### Get Base Template +**Endpoint:** `/api/v1/dashboard/karma-voucher/get-base-template/` +**Method:** `GET` +**Brief:** Download base template for voucher import. +**Sample Response:** (File Download) + +## Dynamic Management + +### List Dynamic Roles +**Endpoint:** `/api/v1/dashboard/dynamic-management/roles/` +**Method:** `GET` +**Brief:** List dynamic roles. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Dynamic Roles Fetched Successfully" + ] + }, + "response": { + "data": [ + { + "type": "type", + "roles": [ + { + "id": "uuid", + "role": "Role Title" + } + ] + } + ], + "pagination": {} + } +} +``` + +### Create Dynamic Role +**Endpoint:** `/api/v1/dashboard/dynamic-management/roles/` +**Method:** `POST` +**Brief:** Create a dynamic role. +**Permissions:** Admin +**Request Body:** +```json +{ + "type": "type", + "role": "role_uuid" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Dynamic Role Created Successfully" + ] + }, + "response": {} +} +``` + +### Edit Dynamic Role +**Endpoint:** `/api/v1/dashboard/dynamic-management/roles//` +**Method:** `PATCH` +**Brief:** Edit a dynamic role. +**Permissions:** Admin +**Request Body:** +```json +{ + "new_role": "role_uuid" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Dynamic Role Updated Successfully" + ] + }, + "response": {} +} +``` + +### Delete Dynamic Role +**Endpoint:** `/api/v1/dashboard/dynamic-management/roles//` +**Method:** `DELETE` +**Brief:** Delete a dynamic role. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Dynamic Role Deleted Successfully" + ] + }, + "response": {} +} +``` + +### List Dynamic Users +**Endpoint:** `/api/v1/dashboard/dynamic-management/users/` +**Method:** `GET` +**Brief:** List dynamic users. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Dynamic Users Fetched Successfully" + ] + }, + "response": { + "data": [ + { + "type": "type", + "users": [ + { + "dynamic_user_id": "uuid", + "user_id": "uuid", + "full_name": "Name", + "muid": "muid", + "email": "email" + } + ] + } + ], + "pagination": {} + } +} +``` + +### Create Dynamic User +**Endpoint:** `/api/v1/dashboard/dynamic-management/users/` +**Method:** `POST` +**Brief:** Create a dynamic user. +**Permissions:** Admin +**Request Body:** +```json +{ + "type": "type", + "user": "muid_or_email" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Dynamic User Created Successfully" + ] + }, + "response": {} +} +``` + +## Error Log Management + +### Get Error Logs +**Endpoint:** `/api/v1/dashboard/error-log/` +**Method:** `GET` +**Brief:** Get error logs (admin only). +**Permissions:** Admin +**Sample Response:** (File Download or Text content) + +### Clear Error Logs +**Endpoint:** `/api/v1/dashboard/error-log/` +**Method:** `POST` +**Brief:** Clear error logs. +**Permissions:** Admin +**Request Body:** +```json +{ + "type": "backend" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Error Log Cleared Successfully" + ] + }, + "response": {} +} +``` diff --git a/api_docs/Dashboard_Mentor.md b/api_docs/Dashboard_Mentor.md new file mode 100644 index 000000000..e0dcc2c6d --- /dev/null +++ b/api_docs/Dashboard_Mentor.md @@ -0,0 +1,1003 @@ +# Dashboard — Mentor API + +**Base path:** `/api/v1/dashboard/mentor/` +**Source:** `api/dashboard/mentor/` +**OpenAPI tags:** `Dashboard - Mentor`, `Dashboard - Mentor Session`, `Dashboard - Mentor Availability`, `Dashboard - Mentor Session Participant`, `Dashboard - Learner Session`, `Dashboard - Mentor Public` + +--- + +## Table of Contents + +| # | Endpoint | Method(s) | Auth / Role | +|---|----------|-----------|-------------| +| 1 | [`register/`](#1-register) | `POST`, `PATCH` | Authenticated user | +| 2 | [`status/`](#2-status) | `GET` | Authenticated user | +| 3 | [`profile/`](#3-profile) | `GET`, `PATCH` | Mentor (approved) | +| 4 | [`list/`](#4-list) | `GET` | Admin | +| 5 | [`detail//`](#5-detailmentor_id) | `GET` | Admin | +| 6 | [`verify//`](#6-verifymentor_id) | `PATCH` | Admin | +| 7 | [`public/profile//`](#7-publicprofilementor_id) | `GET` | Authenticated user | +| 8 | [`public/availability//`](#8-publicavailabilitymentor_id) | `GET` | Authenticated user | +| 9 | [`availability/`](#9-availability) | `GET`, `POST` | Mentor | +| 10 | [`availability//`](#10-availabilityslot_id) | `GET`, `PATCH`, `DELETE` | Mentor | +| 11 | [`session/create/`](#11-sessioncreate) | `POST` | Mentor | +| 12 | [`session/list/`](#12-sessionlist) | `GET` | Mentor | +| 13 | [`session/list//`](#13-sessionlistsession_id) | `GET` | Mentor | +| 14 | [`session/update//`](#14-sessionupdatesession_id) | `PATCH`, `DELETE` | Mentor | +| 15 | [`session/available/`](#15-sessionavailable) | `GET` | Authenticated user (learner) | +| 16 | [`session/admin/list/`](#16-sessionadminlist) | `GET` | Admin | +| 17 | [`session/admin/verify//`](#17-sessionadminverifysession_id) | `PATCH` | Admin | +| 18 | [`session/participation/join//`](#18-sessionparticipationjoinsession_id) | `POST` | Authenticated user | +| 19 | [`session/participant/history/`](#19-sessionparticipanthistory) | `GET` | Authenticated user | +| 20 | [`session/participant/list//`](#20-sessionparticipantlistsession_id) | `GET` | Mentor | +| 21 | [`session/participant/update//`](#21-sessionparticipantupdatelink_id) | `PATCH` | Mentor | +| 22 | [`session/participant/feedback//`](#22-sessionparticipantfeedbacksession_id) | `PATCH` | Authenticated user (participant) | + +**Mentor tasks & admin approval:** see [Dashboard_Mentor_Tasks.md](./Dashboard_Mentor_Tasks.md) (`tasks/ig-dropdown/`, `tasks/`, `tasks//`, `activity/`, admin `task/pending/` & `task//review/`). + +--- + +## Overview + +### Response envelope + +**Success:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Human-readable success message"] }, + "response": {} +} +``` + +**Failure:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Error summary"], + "field_name": ["Validation detail"] + }, + "response": {} +} +``` + +### Authentication + +```http +Authorization: Bearer +``` + +Required on all endpoints in this module (including public profile/availability routes). + +### Pagination & search + +| Query param | Default | Description | +|-------------|---------|-------------| +| `pageIndex` | `1` | Page number | +| `perPage` | `10` | Items per page | +| `search` | — | Case-insensitive search (fields vary) | +| `sortBy` | — | Sort key; prefix `-` for descending | + +**Paginated response:** + +```json +{ + "response": { + "data": [], + "pagination": { + "count": 10, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +### Mentor application lifecycle + +| `status` | Meaning | +|----------|---------| +| `PENDING` | Awaiting admin review | +| `APPROVED` | Mentor role assigned; profile & session APIs available | +| `REJECTED` | Rejected; PATCH `register/` to resubmit | + +**Mentor tiers:** `IG_MENTOR`, `MENTOR`, `COMPANY_MENTOR`, `CAMPUS_MENTOR` (default on register: `IG_MENTOR`) + +### Session lifecycle + +| `status` | Meaning | +|----------|---------| +| `PENDING_APPROVAL` | Created by mentor; awaiting admin | +| `SCHEDULED` | Approved; learners can join | +| `COMPLETED` | Finished | +| `CANCELLED` | Cancelled | +| `REJECTED` | Rejected by admin | + +Editing a `SCHEDULED` session resets status to `PENDING_APPROVAL`. + +--- + +## 1. `register/` + +**`POST /api/v1/dashboard/mentor/register/`** + +Submit a mentor application for the authenticated user. + +**Roles:** Authenticated user (one application per user) + +**Request body:** + +```json +{ + "about": "Software engineer with 8 years of experience mentoring students.", + "expertise": "Python, Django, system design, career guidance", + "reason": "I want to give back to the muLearn community.", + "hours": 5, + "preferred_ig_ids": [ + "ig-uuid-web-dev", + "ig-uuid-cloud" + ] +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `about` | No | Max 1000 chars | +| `expertise` | No | Free text | +| `reason` | No | Max 1000 chars | +| `hours` | No | Weekly hours (integer, default 0) | +| `preferred_ig_ids` | Yes | Non-empty array of valid Interest Group UUIDs | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Mentor registration submitted successfully."] }, + "response": { + "about": "Software engineer with 8 years of experience mentoring students.", + "expertise": "Python, Django, system design, career guidance", + "reason": "I want to give back to the muLearn community.", + "hours": 5, + "preferred_ig_ids": ["ig-uuid-web-dev", "ig-uuid-cloud"] + } +} +``` + +**Error:** Mentor request already exists for this account. + +--- + +**`PATCH /api/v1/dashboard/mentor/register/`** + +Update a pending or rejected application. Rejected applications are resubmitted as `PENDING` with `verification_note` cleared. + +**Request body:** Same fields as POST (partial update). + +**Success response:** Updated application fields. + +**Errors:** No application found; already `APPROVED` (use `profile/`). + +--- + +## 2. `status/` + +**`GET /api/v1/dashboard/mentor/status/`** + +Check mentor application status. + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "status": "PENDING", + "verification_note": null, + "mentor_id": "mentor-uuid" + } +} +``` + +--- + +## 3. `profile/` + +**`GET /api/v1/dashboard/mentor/profile/`** + +Full mentor profile for an approved mentor. + +**Roles:** `Mentor` with `status = APPROVED` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "id": "mentor-uuid", + "user": "user-uuid", + "user_full_name": "Arjun Menon", + "user_email": "arjun@example.com", + "about": "Software engineer with 8 years of experience.", + "expertise": "Python, Django", + "reason": "Giving back to the community.", + "hours": 5, + "mentor_tier": "IG_MENTOR", + "status": "APPROVED", + "preferred_ig_ids": ["ig-uuid-web-dev"], + "org": null, + "verified_by": "admin-uuid", + "verified_at": "2026-02-01T10:00:00Z", + "verification_note": null, + "created_by": "user-uuid", + "updated_by": "user-uuid", + "created_at": "2026-01-15T10:00:00Z", + "updated_at": "2026-05-01T10:00:00Z" + } +} +``` + +--- + +**`PATCH /api/v1/dashboard/mentor/profile/`** + +Update approved mentor profile. + +**Request example:** + +```json +{ + "about": "Updated bio.", + "hours": 8, + "preferred_ig_ids": ["ig-uuid-web-dev", "ig-uuid-ml"] +} +``` + +**Success response:** Full mentor detail object (same shape as GET). + +--- + +## 4. `list/` + +**`GET /api/v1/dashboard/mentor/list/`** + +Admin list of all mentor applications. + +**Roles:** `Admin` + +**Query params:** + +| Param | Description | +|-------|-------------| +| `status` | `PENDING`, `APPROVED`, `REJECTED` | +| `mentor_tier` | `IG_MENTOR`, `MENTOR`, `COMPANY_MENTOR`, `CAMPUS_MENTOR` | +| `pageIndex`, `perPage`, `search`, `sortBy` | Search `user__full_name`, `user__email`; sort `created_at`, `status`, `user_full_name` | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "mentor-uuid", + "user_id": "user-uuid", + "user_full_name": "Arjun Menon", + "user_email": "arjun@example.com", + "mentor_tier": "IG_MENTOR", + "status": "PENDING", + "created_at": "2026-01-15T10:00:00Z", + "updated_at": "2026-01-15T10:00:00Z" + } + ], + "pagination": { "count": 1, "totalPages": 1, "isNext": false, "isPrev": false, "nextPage": null } + } +} +``` + +--- + +## 5. `detail//` + +**`GET /api/v1/dashboard/mentor/detail//`** + +Admin detail for one mentor record. + +**Roles:** `Admin` + +**Success response:** Full mentor object (same shape as [profile GET](#3-profile)). + +--- + +## 6. `verify//` + +**`PATCH /api/v1/dashboard/mentor/verify//`** + +Approve or reject a mentor application. On `APPROVED`, assigns the `Mentor` role and creates `UserIgLink` rows for each `preferred_ig_id` when tier is `IG_MENTOR`. + +**Roles:** `Admin` + +**Request body — Approve:** + +```json +{ + "status": "APPROVED" +} +``` + +**Request body — Reject:** + +```json +{ + "status": "REJECTED", + "verification_note": "Insufficient detail in expertise section." +} +``` + +| Field | Required | +|-------|----------| +| `status` | Yes — `APPROVED` or `REJECTED` | +| `verification_note` | Required when `REJECTED` | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Mentor status updated to APPROVED successfully."] }, + "response": {} +} +``` + +--- + +## 7. `public/profile//` + +**`GET /api/v1/dashboard/mentor/public/profile//`** + +View an approved mentor's public profile. + +**Roles:** Authenticated user + +**Path params:** `mentor_id` — `UserMentor.id` UUID + +**Success response:** Full mentor detail (approved mentors only). + +--- + +## 8. `public/availability//` + +**`GET /api/v1/dashboard/mentor/public/availability//`** + +List active availability slots for an approved mentor. + +**Roles:** Authenticated user + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": [ + { + "id": "slot-uuid", + "mentor_user_id": "user-uuid", + "ig_id": "ig-uuid", + "ig_name": "Web Development", + "weekday": 2, + "start_time": "18:00:00", + "end_time": "20:00:00", + "timezone": "Asia/Kolkata", + "is_active": true, + "valid_from": "2026-05-01", + "valid_to": "2026-12-31", + "created_at": "2026-04-01T10:00:00Z", + "updated_at": "2026-04-01T10:00:00Z" + } + ] +} +``` + +`weekday`: `1` = Monday … `7` = Sunday + +--- + +## 9. `availability/` + +**`GET /api/v1/dashboard/mentor/availability/`** + +List the logged-in mentor's availability slots (paginated). + +**Roles:** `Mentor` + +**Query params:** + +| Param | Description | +|-------|-------------| +| `ig_id` | Filter by Interest Group | +| `is_active` | `true` or `false` | +| `pageIndex`, `perPage`, `search`, `sortBy` | Sort: `weekday`, `start_time`, `created_at` | + +**Success response:** Paginated array of slot objects (same shape as [public availability](#8-publicavailabilitymentor_id)). + +--- + +**`POST /api/v1/dashboard/mentor/availability/`** + +Create an availability slot. Mentor must be assigned to the IG with `assignment_type = MENTOR`. + +**Request body:** + +```json +{ + "ig": "ig-uuid-web-dev", + "weekday": 2, + "start_time": "18:00:00", + "end_time": "20:00:00", + "timezone": "Asia/Kolkata", + "is_active": true, + "valid_from": "2026-05-01", + "valid_to": "2026-12-31" +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `ig` | Yes | Interest Group UUID | +| `weekday` | Yes | 1–7 (Mon–Sun) | +| `start_time` | Yes | `HH:MM:SS` | +| `end_time` | Yes | Must be after `start_time` | +| `timezone` | No | Default `Asia/Kolkata` | +| `is_active` | No | Default `true` | +| `valid_from`, `valid_to` | No | Optional date range | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Availability slot created successfully."] }, + "response": { + "ig": "ig-uuid-web-dev", + "weekday": 2, + "start_time": "18:00:00", + "end_time": "20:00:00", + "timezone": "Asia/Kolkata", + "is_active": true, + "valid_from": "2026-05-01", + "valid_to": "2026-12-31" + } +} +``` + +**Error:** Not assigned as mentor for the given IG. + +--- + +## 10. `availability//` + +**`GET /api/v1/dashboard/mentor/availability//`** + +Single slot detail for the logged-in mentor. + +**Success response:** Single slot object (not paginated). + +--- + +**`PATCH /api/v1/dashboard/mentor/availability//`** + +Update a slot (partial). Same body fields as POST. + +**Success response:** Updated slot fields. + +--- + +**`DELETE /api/v1/dashboard/mentor/availability//`** + +Permanently delete a slot. + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Availability slot deleted successfully."] }, + "response": {} +} +``` + +--- + +## 11. `session/create/` + +**`POST /api/v1/dashboard/mentor/session/create/`** + +Create a mentorship session (starts in `PENDING_APPROVAL`). Mentor must be assigned to the session's IG. + +**Roles:** `Mentor` + +**Request body:** + +```json +{ + "ig": "ig-uuid-web-dev", + "title": "Intro to REST APIs", + "description": "Hands-on session covering HTTP verbs, status codes, and DRF basics.", + "mode": "ONLINE", + "starts_at": "2026-06-15T14:00:00Z", + "ends_at": "2026-06-15T16:00:00Z", + "meeting_link": "https://meet.example.com/abc-defg-hij", + "venue": null, + "max_participants": 30 +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `ig` | Yes | Interest Group UUID | +| `title` | Yes | Max 150 chars | +| `description` | No | | +| `mode` | Yes | `ONLINE`, `OFFLINE`, `HYBRID` | +| `starts_at` | Yes | ISO 8601 datetime | +| `ends_at` | Yes | Must be after `starts_at` | +| `meeting_link` | No | For online/hybrid | +| `venue` | No | For offline/hybrid | +| `max_participants` | No | Cap on joins | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Session created successfully and is pending approval."] }, + "response": { + "ig": "ig-uuid-web-dev", + "title": "Intro to REST APIs", + "description": "Hands-on session covering HTTP verbs, status codes, and DRF basics.", + "mode": "ONLINE", + "starts_at": "2026-06-15T14:00:00Z", + "ends_at": "2026-06-15T16:00:00Z", + "meeting_link": "https://meet.example.com/abc-defg-hij", + "venue": null, + "max_participants": 30 + } +} +``` + +--- + +## 12. `session/list/` + +**`GET /api/v1/dashboard/mentor/session/list/`** + +List sessions created by the logged-in mentor. + +**Roles:** `Mentor` + +**Query params:** + +| Param | Description | +|-------|-------------| +| `status` | Filter by session status | +| `pageIndex`, `perPage`, `search`, `sortBy` | Search `title`, `description`, `ig__name`; sort `created_at`, `starts_at` | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "session-uuid", + "ig_id": "ig-uuid", + "ig_name": "Web Development", + "title": "Intro to REST APIs", + "mode": "ONLINE", + "starts_at": "2026-06-15T14:00:00Z", + "ends_at": "2026-06-15T16:00:00Z", + "status": "PENDING_APPROVAL", + "created_by_id": "user-uuid", + "created_by_name": "Arjun Menon", + "created_at": "2026-05-20T10:00:00Z", + "max_participants": 30 + } + ], + "pagination": { "count": 1, "totalPages": 1, "isNext": false, "isPrev": false, "nextPage": null } + } +} +``` + +--- + +## 13. `session/list//` + +**`GET /api/v1/dashboard/mentor/session/list//`** + +Single session detail (mentor must be the creator). + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "id": "session-uuid", + "ig_id": "ig-uuid", + "ig_name": "Web Development", + "title": "Intro to REST APIs", + "mode": "ONLINE", + "starts_at": "2026-06-15T14:00:00Z", + "ends_at": "2026-06-15T16:00:00Z", + "status": "SCHEDULED", + "created_by_id": "user-uuid", + "created_by_name": "Arjun Menon", + "created_at": "2026-05-20T10:00:00Z", + "max_participants": 30, + "description": "Hands-on session covering HTTP verbs and DRF.", + "meeting_link": "https://meet.example.com/abc-defg-hij", + "venue": null + } +} +``` + +--- + +## 14. `session/update//` + +**`PATCH /api/v1/dashboard/mentor/session/update//`** + +Update a session. Cannot edit `COMPLETED`, `CANCELLED`, or `REJECTED` sessions. Editing a `SCHEDULED` session resets status to `PENDING_APPROVAL`. + +**Request example:** + +```json +{ + "title": "Intro to REST APIs (Updated)", + "starts_at": "2026-06-16T14:00:00Z", + "ends_at": "2026-06-16T16:00:00Z", + "max_participants": 25 +} +``` + +**Success response:** Updated session fields. + +--- + +**`DELETE /api/v1/dashboard/mentor/session/update//`** + +Soft-delete a session (`is_deleted = true`). + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Session deleted successfully."] }, + "response": {} +} +``` + +--- + +## 15. `session/available/` + +**`GET /api/v1/dashboard/mentor/session/available/`** + +List `SCHEDULED` sessions for Interest Groups the current user belongs to (learner discovery). + +**Roles:** Authenticated user + +**Success response:** Paginated session list (same item shape as [session list](#12-sessionlist)). + +--- + +## 16. `session/admin/list/` + +**`GET /api/v1/dashboard/mentor/session/admin/list/`** + +Admin view of all non-deleted sessions. + +**Roles:** `Admin` + +**Query params:** `status`, `ig_id`, plus pagination/search + +**Success response:** Paginated session list. + +--- + +## 17. `session/admin/verify//` + +**`PATCH /api/v1/dashboard/mentor/session/admin/verify//`** + +Approve or reject a session in `PENDING_APPROVAL`. + +**Roles:** `Admin` + +**Request body — Schedule:** + +```json +{ + "status": "SCHEDULED" +} +``` + +**Request body — Reject:** + +```json +{ + "status": "REJECTED" +} +``` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Session status updated to SCHEDULED successfully."] }, + "response": {} +} +``` + +**Error:** Only `PENDING_APPROVAL` sessions can be verified. + +--- + +## 18. `session/participation/join//` + +**`POST /api/v1/dashboard/mentor/session/participation/join//`** + +Join a scheduled session as a mentee. + +**Roles:** Authenticated user + +**Request body:** None (empty `{}` is fine) + +**Rules:** + +- Session must be `SCHEDULED` +- User must not already be registered +- Respects `max_participants` if set + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Successfully joined the session."] }, + "response": { + "id": "link-uuid", + "session_id": "session-uuid", + "user_id": "user-uuid", + "user_full_name": "Riya Sharma", + "mu_id": "riya-sharma@mulearn", + "participant_role": "MENTEE", + "attendance_status": "INVITED", + "progress_note": null, + "feedback": null, + "contributed_minutes": null, + "created_at": "2026-06-10T10:00:00Z" + } +} +``` + +**Participant roles:** `MENTOR`, `MENTEE`, `CO_MENTOR` +**Attendance statuses:** `INVITED`, `ATTENDED`, `ABSENT` + +--- + +## 19. `session/participant/history/` + +**`GET /api/v1/dashboard/mentor/session/participant/history/`** + +Sessions the current user has joined. + +**Success response:** Paginated array of participant link objects (same shape as join response). + +--- + +## 20. `session/participant/list//` + +**`GET /api/v1/dashboard/mentor/session/participant/list//`** + +Mentor lists all participants for a session they created. + +**Roles:** `Mentor` (session creator) + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "link-uuid", + "session_id": "session-uuid", + "user_id": "user-uuid", + "user_full_name": "Riya Sharma", + "mu_id": "riya-sharma@mulearn", + "participant_role": "MENTEE", + "attendance_status": "INVITED", + "progress_note": null, + "feedback": null, + "contributed_minutes": null, + "created_at": "2026-06-10T10:00:00Z" + } + ], + "pagination": { "count": 1, "totalPages": 1, "isNext": false, "isPrev": false, "nextPage": null } + } +} +``` + +--- + +## 21. `session/participant/update//` + +**`PATCH /api/v1/dashboard/mentor/session/participant/update//`** + +Mentor updates attendance and progress for a participant. + +**Roles:** `Mentor` (must own the parent session) + +**Request body:** + +```json +{ + "attendance_status": "ATTENDED", + "progress_note": "Completed exercises 1–3; strong grasp of REST concepts.", + "contributed_minutes": 90 +} +``` + +| Field | Notes | +|-------|-------| +| `attendance_status` | `INVITED`, `ATTENDED`, `ABSENT` | +| `progress_note` | Max 500 chars | +| `contributed_minutes` | Must be > 0 if provided | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Participant record updated successfully."] }, + "response": { + "attendance_status": "ATTENDED", + "progress_note": "Completed exercises 1–3; strong grasp of REST concepts.", + "contributed_minutes": 90 + } +} +``` + +--- + +## 22. `session/participant/feedback//` + +**`PATCH /api/v1/dashboard/mentor/session/participant/feedback//`** + +Participant submits feedback after attending a session. + +**Roles:** Authenticated user (must be a participant with `attendance_status = ATTENDED`) + +**Request body:** + +```json +{ + "feedback": "Very clear explanations and helpful Q&A. Would attend again." +} +``` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Feedback submitted successfully."] }, + "response": { + "id": "link-uuid", + "session_id": "session-uuid", + "user_id": "user-uuid", + "user_full_name": "Riya Sharma", + "mu_id": "riya-sharma@mulearn", + "participant_role": "MENTEE", + "attendance_status": "ATTENDED", + "progress_note": null, + "feedback": "Very clear explanations and helpful Q&A. Would attend again.", + "contributed_minutes": null, + "created_at": "2026-06-10T10:00:00Z" + } +} +``` + +**Errors:** Not a participant; feedback empty; attendance not `ATTENDED`. + +--- + +## Usage flows + +### Mentor onboarding + +```mermaid +sequenceDiagram + participant U as User + participant API as Mentor API + participant Admin as Admin + + U->>API: POST /register/ + API-->>U: status PENDING + U->>API: GET /status/ + Admin->>API: PATCH /verify/{id}/ APPROVED + Note over U,API: Mentor role + IG links created + U->>API: GET /profile/ +``` + +### Session from creation to feedback + +```mermaid +sequenceDiagram + participant M as Mentor + participant API as Mentor API + participant Admin as Admin + participant L as Learner + + M->>API: POST /session/create/ + Admin->>API: PATCH /session/admin/verify/{id}/ SCHEDULED + L->>API: GET /session/available/ + L->>API: POST /session/participation/join/{id}/ + M->>API: PATCH /session/participant/update/{link_id}/ + L->>API: PATCH /session/participant/feedback/{id}/ +``` + +### Availability management + +```mermaid +flowchart LR + A[Mentor assigned to IG] --> B[POST /availability/] + B --> C[Public GET /public/availability/{mentor_id}/] + B --> D[PATCH or DELETE /availability/{slot_id}/] +``` + +--- + +## Related + +- Company dashboard (jobs, talent directory): [Dashboard_Company.md](./Dashboard_Company.md) +- Interactive schema: `/api/docs/` (when `ENABLE_SWAGGER=true`) +- OpenAPI: `/api/schema/` diff --git a/api_docs/Dashboard_Mentor_Tasks.md b/api_docs/Dashboard_Mentor_Tasks.md new file mode 100644 index 000000000..067ed3d2a --- /dev/null +++ b/api_docs/Dashboard_Mentor_Tasks.md @@ -0,0 +1,668 @@ +# Dashboard — Mentor Tasks & Admin Task Approval + +**Mentor base path:** `/api/v1/dashboard/mentor/` +**Admin approval base path:** `/api/v1/dashboard/task/` +**Source:** `api/dashboard/mentor/task_views.py`, `api/dashboard/mentor/mentor_views.py`, `api/dashboard/task/dash_task_view.py` +**OpenAPI tags:** `Dashboard - Mentor Task`, `Dashboard - Mentor`, `Dashboard - Task` + +Related overview: [Dashboard_Mentor.md](./Dashboard_Mentor.md) + +--- + +## Table of Contents + +| # | Endpoint | Method(s) | Role | +|---|----------|-----------|------| +| 1 | [`tasks/ig-dropdown/`](#1-tasksig-dropdown) | `GET` | Mentor | +| 2 | [`tasks/`](#2-tasks) | `GET`, `POST` | Mentor | +| 3 | [`tasks//`](#3-taskstask_id) | `GET`, `PUT`, `DELETE` | Mentor | +| 4 | [`activity/`](#4-activity) | `GET` | Mentor, Campus Lead, Lead Enabler | +| 5 | [`task/pending/`](#5-admin-list-pending-tasks) | `GET` | Admin | +| 6 | [`task//review/`](#6-admin-approve-or-reject-task) | `PATCH` | Admin | + +--- + +## Overview + +### Response envelope + +**Success:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Human-readable success message"] }, + "response": {} +} +``` + +**Failure:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Error summary"], + "field_name": ["Validation detail"] + }, + "response": {} +} +``` + +### Authentication + +```http +Authorization: Bearer +``` + +Required on all endpoints below. + +### Pagination & search (mentor task list & activity) + +| Query param | Default | Description | +|-------------|---------|-------------| +| `pageIndex` | `1` | Page number | +| `perPage` | `10` | Items per page | +| `search` | — | Case-insensitive search (fields vary per endpoint) | +| `sortBy` | — | Sort key; prefix `-` for descending | + +**Paginated response shape** (used by `tasks/` GET and `activity/`): + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [], + "pagination": { + "count": 10, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +### Mentor task approval lifecycle + +| `approval_status` | `active` | Meaning | +|-------------------|----------|---------| +| `pending` | `false` | Submitted by mentor; awaiting admin review | +| `approved` | `true` | Admin approved; task is live for learners | +| `rejected` | `false` | Admin rejected; see `rejection_reason` | + +- On **create**, tasks are saved as `pending` / `active=false`. +- On **edit** (`PUT`), status resets to `pending` and `active=false` (re-approval required). +- Only **pending** tasks can be **deleted** by the mentor. + +--- + +## 1. `tasks/ig-dropdown/` + +**`GET /api/v1/dashboard/mentor/tasks/ig-dropdown/`** + +Returns Interest Groups where the authenticated user has an active `MENTOR` assignment (`user_ig_link`). Use this to populate the IG selector when creating a task. + +**Roles:** `Mentor` + +**Query params:** None + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": [ + { + "id": "ig-uuid-web-dev", + "name": "Web Development" + }, + { + "id": "ig-uuid-cloud", + "name": "Cloud & DevOps" + } + ] +} +``` + +**Notes:** + +- Empty array if the mentor has no active IG mentor links. +- Only IGs from `UserIgLink` with `assignment_type=MENTOR` and `is_active=true` are returned. + +--- + +## 2. `tasks/` + +### List mentor-submitted tasks + +**`GET /api/v1/dashboard/mentor/tasks/`** + +Lists all tasks where `requested_by` is the authenticated mentor. + +**Roles:** `Mentor` + +**Query params:** + +| Param | Required | Description | +|-------|----------|-------------| +| `approval_status` | No | Filter: `pending`, `approved`, or `rejected` | +| `pageIndex` | No | Page number (default `1`) | +| `perPage` | No | Page size (default `10`) | +| `search` | No | Searches `hashtag`, `title`, `description`, `karma`, `ig__name`, `type__title`, `approval_status` | +| `sortBy` | No | e.g. `title`, `-created_at`, `approval_status`, `karma` | + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "task-uuid-001", + "hashtag": "#build-rest-api", + "discord_link": null, + "title": "Build a REST API", + "description": "Create a CRUD API with authentication.", + "karma": 250, + "channel": "web-dev", + "type": "Implementation", + "active": false, + "variable_karma": false, + "usage_count": 1, + "level": "Level 3", + "org": null, + "ig": "Web Development", + "event": null, + "bonus_karma": null, + "bonus_time": null, + "approval_status": "pending", + "rejection_reason": null, + "reviewed_at": null, + "requested_by_name": "Arjun Menon", + "requested_at": "2026-05-20T09:00:00Z", + "skills": [ + { + "id": "skill-uuid-python", + "name": "Python", + "code": "python" + } + ], + "created_at": "2026-05-20T09:00:00Z", + "updated_at": "2026-05-20T09:00:00Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +### Create a task (submit for approval) + +**`POST /api/v1/dashboard/mentor/tasks/`** + +Creates a task for an IG the mentor belongs to. Saved as `approval_status=pending`, `active=false` until an admin approves it. + +**Roles:** `Mentor` + +**Request body:** + +```json +{ + "hashtag": "#build-rest-api", + "title": "Build a REST API", + "karma": 250, + "usage_count": 1, + "description": "Create a CRUD API with authentication and tests.", + "type": "task-type-uuid", + "level": "level-uuid", + "ig": "ig-uuid-web-dev", + "skill_ids": [ + "skill-uuid-python", + "skill-uuid-django" + ] +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `hashtag` | Yes | Must be globally unique | +| `title` | Yes | Max 75 chars | +| `karma` | Yes | Integer | +| `usage_count` | No | Default `1` on model | +| `description` | No | Text | +| `type` | Yes | `TaskType` UUID | +| `level` | No | `Level` UUID | +| `ig` | Yes | Must be an IG from `tasks/ig-dropdown/` (active mentor assignment) | +| `skill_ids` | No | Array of active skill UUIDs; replaces skill links on create | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task submitted for approval."] }, + "response": {} +} +``` + +**Validation errors (example):** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "hashtag": ["A task with this hashtag already exists."], + "ig": ["You are not assigned as a mentor for this Interest Group."] + }, + "response": {} +} +``` + +--- + +## 3. `tasks//` + +### Get task detail + +**`GET /api/v1/dashboard/mentor/tasks//`** + +**Roles:** `Mentor` (only tasks where `requested_by` is the current user) + +**Request body:** None + +**Success response:** Single task object (same shape as one item in the list `data` array above). + +**Error:** `404` — Task not found or not owned by this mentor. + +--- + +### Update task (re-submit for approval) + +**`PUT /api/v1/dashboard/mentor/tasks//`** + +Partial updates allowed. After save, task is reset to `pending`, `active=false`, and review fields cleared. + +**Roles:** `Mentor` + +**Request body (partial example):** + +```json +{ + "title": "Build a REST API (updated)", + "karma": 300, + "description": "Added pagination requirements.", + "skill_ids": ["skill-uuid-python"] +} +``` + +Writable fields: `hashtag`, `title`, `karma`, `usage_count`, `description`, `type`, `level`, `ig`, `skill_ids`. + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task updated and re-submitted for approval."] }, + "response": {} +} +``` + +--- + +### Delete task + +**`DELETE /api/v1/dashboard/mentor/tasks//`** + +**Roles:** `Mentor` + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task deleted successfully."] }, + "response": {} +} +``` + +**Error:** Only `pending` tasks can be deleted. + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Cannot delete a task with status 'approved'. Only pending tasks can be deleted."] + }, + "response": {} +} +``` + +--- + +## 4. `activity/` + +**`GET /api/v1/dashboard/mentor/activity/`** + +Returns a merged, paginated timeline of: + +1. **Sessions created** by the user (`SESSION_CREATED`) +2. **Learner task submissions appraised** by the user (`TASK_APPRAISED` — karma activity logs where `appraiser_approved_by` is this user) + +This is **not** the admin task-approval queue; it is the mentor’s own activity feed. + +**Roles:** `Mentor`, `Campus Lead`, `Lead Enabler` + +**Query params:** `pageIndex`, `perPage`, `search` (on `title`, `activity_type`, `status`), `sortBy` (e.g. `date`, `-date`) + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "session-uuid-abc", + "activity_type": "SESSION_CREATED", + "title": "Intro to System Design", + "description": "Weekly mentorship session for Web Dev IG.", + "date": "2026-05-28T14:00:00Z", + "status": "SCHEDULED" + }, + { + "id": "karma-log-uuid-xyz", + "activity_type": "TASK_APPRAISED", + "title": "Build a REST API", + "description": null, + "date": "2026-05-27T11:30:00Z", + "status": "Approved" + }, + { + "id": "karma-log-uuid-def", + "activity_type": "TASK_APPRAISED", + "title": "Deploy to AWS", + "description": null, + "date": "2026-05-26T09:15:00Z", + "status": "Pending" + } + ], + "pagination": { + "count": 3, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +| `activity_type` | `status` values | +|-----------------|-----------------| +| `SESSION_CREATED` | Session status: `PENDING_APPROVAL`, `SCHEDULED`, `COMPLETED`, `CANCELLED`, `REJECTED` | +| `TASK_APPRAISED` | `Pending`, `Approved`, or `Rejected` (from `KarmaActivityLog.appraiser_approved`) | + +--- + +# Admin — Mentor task verification + +Admins review mentor-submitted tasks (and company-submitted tasks) via the task dashboard module. + +**Base path:** `/api/v1/dashboard/task/` + +--- + +## 5. Admin — list pending tasks + +**`GET /api/v1/dashboard/task/pending/`** + +Lists tasks filtered by approval status and optional submitter role. + +**Roles:** `Admin` + +**Query params:** + +| Param | Default | Description | +|-------|---------|-------------| +| `approval_status` | `pending` | `pending`, `approved`, or `rejected` | +| `role` | — | Filter submitter: `mentor`, `company`, or `admin` (tasks with no `requested_by`) | +| `mentor_name` | — | Filter by mentor full name (when reviewing mentor tasks) | +| `company_name` | — | Filter by company name | +| `pageIndex`, `perPage`, `search`, `sortBy` | — | Pagination; search on `title`, `hashtag`, company/mentor names | + +**Example — mentor tasks awaiting review:** + +```http +GET /api/v1/dashboard/task/pending/?approval_status=pending&role=mentor&pageIndex=1&perPage=10 +``` + +**Request body:** None + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Tasks fetched successfully."] }, + "response": { + "tasks": [ + { + "id": "task-uuid-001", + "title": "Build a REST API", + "hashtag": "#build-rest-api", + "description": "Create a CRUD API with authentication.", + "karma": 250, + "approval_status": "pending", + "ig": { + "id": "ig-uuid-web-dev", + "name": "Web Development" + }, + "type": { + "id": "task-type-uuid", + "title": "Implementation" + }, + "company_name": null, + "requested_by": { + "id": "user-uuid-mentor", + "full_name": "Arjun Menon" + }, + "requested_at": "2026-05-20T09:00:00+00:00", + "created_at": "2026-05-20T09:00:00+00:00" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +## 6. Admin — approve or reject task + +**`PATCH /api/v1/dashboard/task//review/`** + +Approve or reject a **pending** task. Only tasks with `approval_status=pending` can be reviewed. + +**Roles:** `Admin` + +### Approve + +**Request body:** + +```json +{ + "action": "approve" +} +``` + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task approved and is now live."] }, + "response": { + "task_id": "task-uuid-001", + "approval_status": "approved", + "active": true, + "rejection_reason": null, + "reviewed_by": "admin-user-uuid", + "reviewed_at": "2026-05-21T10:30:00+00:00" + } +} +``` + +### Reject + +**Request body:** + +```json +{ + "action": "reject", + "reason": "Hashtag does not follow IG naming guidelines. Please resubmit with #webdev- prefix." +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `action` | Yes | `approve` or `reject` | +| `reason` | Yes when `action=reject` | Stored in `rejection_reason` | + +**Success response:** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Task rejected."] }, + "response": { + "task_id": "task-uuid-001", + "approval_status": "rejected", + "active": false, + "rejection_reason": "Hashtag does not follow IG naming guidelines. Please resubmit with #webdev- prefix.", + "reviewed_by": "admin-user-uuid", + "reviewed_at": "2026-05-21T10:35:00+00:00" + } +} +``` + +### Error responses + +**Invalid action:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Invalid action. Must be 'approve' or 'reject'."], + "error_code": ["INVALID_ACTION"] + }, + "response": {} +} +``` + +**Task not pending:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Only pending tasks can be reviewed. Current status: 'approved'."], + "error_code": ["INVALID_STATUS_TRANSITION"] + }, + "response": {} +} +``` + +**Reject without reason:** + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["A rejection reason is required."], + "error_code": ["REASON_REQUIRED"] + }, + "response": {} +} +``` + +--- + +## End-to-end flow + +```mermaid +sequenceDiagram + participant M as Mentor + participant API as Mentor Task API + participant A as Admin + participant T as Task API (Admin) + + M->>API: GET tasks/ig-dropdown/ + API-->>M: IG list + M->>API: POST tasks/ (pending) + API-->>M: Submitted for approval + A->>T: GET task/pending/?role=mentor + T-->>A: Pending mentor tasks + A->>T: PATCH task/{id}/review/ approve + T-->>A: approved, active=true + M->>API: GET tasks/?approval_status=approved + API-->>M: Live task in mentor list +``` + +--- + +## Related endpoints (not in this module) + +| Concern | Where | +|---------|--------| +| Mentor registration / profile | `/api/v1/dashboard/mentor/register/`, `profile/` — [Dashboard_Mentor.md](./Dashboard_Mentor.md) | +| Admin task CRUD (non-approval) | `/api/v1/dashboard/task/` — full task management | +| Learner karma submission appraisal | Discord moderator / LC flows (`KarmaActivityLog.appraiser_approved`) — surfaced in mentor `activity/` as `TASK_APPRAISED` | +| Dropdowns for `type`, `level`, `channel` (admin) | `/api/v1/dashboard/task/task-types/`, `level/`, `channel/` | +| Company-submitted tasks | [Dashboard_Company_Tasks.md](./Dashboard_Company_Tasks.md) | diff --git a/api_docs/Dashboard_Organisation.md b/api_docs/Dashboard_Organisation.md new file mode 100644 index 000000000..b3bf7955b --- /dev/null +++ b/api_docs/Dashboard_Organisation.md @@ -0,0 +1,722 @@ +# Dashboard / Organisation + + +Base path: `/api/dashboard/organisation/` + + +## Endpoint: `add` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/edit//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:org_code` +- Request body example (JSON): +```json +{ + "org_code": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:org_code` +- Request body example (JSON): +```json +{ + "org_code": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes//csv/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:org_type` +- Request body example (JSON): +```json +{ + "org_type": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/info//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:org_code` +- Request body example (JSON): +```json +{ + "org_code": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/prefill//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:org_code` +- Request body example (JSON): +```json +{ + "org_code": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:org_type` +- Request body example (JSON): +```json +{ + "org_type": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes///` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:org_type` + - `str:district_id` +- Request body example (JSON): +```json +{ + "org_type": "", + "district_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/org/affiliation/show/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/org/affiliation/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/org/affiliation/edit//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:affiliation_id` +- Request body example (JSON): +```json +{ + "affiliation_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `institutes/org/affiliation/delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:affiliation_id` +- Request body example (JSON): +```json +{ + "affiliation_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `departments/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `departments/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `departments/edit//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:department_id` +- Request body example (JSON): +```json +{ + "department_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `departments/delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:department_id` +- Request body example (JSON): +```json +{ + "department_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `affiliation/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `merge_organizations//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:organisation_id` +- Request body example (JSON): +```json +{ + "organisation_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `karma-type/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `karma-log/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `base-template/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `import/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `transfer/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verify/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verify//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:uorg_id` +- Request body example (JSON): +```json +{ + "uorg_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Profile.md b/api_docs/Dashboard_Profile.md new file mode 100644 index 000000000..9a8113eae --- /dev/null +++ b/api_docs/Dashboard_Profile.md @@ -0,0 +1,619 @@ +# Dashboard / Profile + + +Base path: `/api/dashboard/profile/` + + +## Endpoint: `badges/` + +### `GET badges/` +Returns the badges earned by a user — each badge corresponds to a task the user completed that belongs to a tracked hashtag category. No auth required. + +**Path params:** +- `muid` — the mulearn ID of the user (e.g. `MU-1234`) + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "full_name": "Alice", + "completed_tasks": [ + "Introduction to Python", + "Build a REST API", + "Deploy with Docker" + ] + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `full_name` | string | Display name of the user | +| `completed_tasks` | string[] | Titles of tasks the user has completed that carry a badge hashtag | + +**Response (user not found):** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { "general": ["The given muid seems to be invalid"] }, + "response": {} +} +``` + + +## Endpoint: `user-profile/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `ig-edit/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-profile//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `edit-user-profile/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-log/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-log//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `share-user-profile/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `share-user-profile//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:uuid` +- Request body example (JSON): +```json +{ + "uuid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `rank//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `get-user-levels/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `get-user-levels//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `socials/edit/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `socials/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `socials//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `qrcode-get//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:uuid` +- Request body example (JSON): +```json +{ + "uuid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `change-password/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `userterm-approved//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `karma-feed/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-level-feed/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-preferences/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `permute//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Projects.md b/api_docs/Dashboard_Projects.md new file mode 100644 index 000000000..2bb3fbd7c --- /dev/null +++ b/api_docs/Dashboard_Projects.md @@ -0,0 +1,433 @@ +# Dashboard / Projects + +Base path: `/api/v1/dashboard/projects/` + +All write endpoints require a valid JWT (`Authorization: Bearer `). +Read endpoints (GET) accept optional auth — unauthenticated requests see only `published` projects. + +--- + +## Endpoint: `` (collection) + +### `GET /` +List projects. Results are paginated. + +**Query params:** + +| Param | Type | Description | +|-------|------|-------------| +| `muid` | string | Filter to projects created by or featuring this mulearn ID | +| `created_by` | string (UUID) | Filter to projects created by this user ID | +| `status` | `draft` \| `published` \| `archived` | Filter by status. Omitting this returns all statuses for the owner; public visitors always see only `published` | +| `search` | string | Full-text search on `title` and `description` | +| `sort_by` | string | Sort field, e.g. `created_at` | +| `page` | integer | Page number | +| `per_page` | integer | Items per page | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "Projects": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "My Project", + "description": "A short description of the project.", + "status": "published", + "logo": "https://example.com/media/projects/logos/logo.png", + "images": [{ "image": "https://example.com/media/projects/images/img.png" }], + "links": [ + { "id": "...", "label": "GitHub", "url": "https://github.com/...", "position": 0 } + ], + "skills": [ + { "id": "...", "name": "Python", "code": "PY", "icon": null } + ], + "members": [ + { + "id": "...", "is_linked": true, "user_id": "...", + "muid": "MU-1234", "full_name": "Alice", "profile_pic": null, + "external_name": null, "role": "Backend", "created_at": "2025-01-01T00:00:00Z" + } + ], + "votes": [ + { "id": "...", "vote": "upvote", "project": "...", "user": "Alice", "user_id": "...", "created_at": "...", "updated_at": "..." } + ], + "comments": [ + { "id": "...", "comment": "Great work!", "project": "...", "user": "Bob", "user_id": "...", "created_at": "...", "updated_at": "..." } + ], + "created_by": "Alice", + "created_by_id": "550e8400-e29b-41d4-a716-446655440001", + "updated_by": "Alice", + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-02T00:00:00Z" + } + ], + "pagination": { + "count": 42, + "totalPages": 5, + "isNext": true, + "isPrev": false, + "nextPage": 2 + } + } +} +``` + +--- + +### `POST /` +Create a new project. **Auth required.** +Request must be `multipart/form-data` (because of file fields). + +**Form fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `title` | string | Yes | Project title (max 50 chars) | +| `description` | string | Yes | Project description | +| `status` | `draft` \| `published` \| `archived` | No | Defaults to `published` | +| `logo` | file | No | Project logo image | +| `images` | file[] | No | Additional project screenshots | +| `links_json` | string (JSON) | No | JSON array of `{ label, url, position? }` objects | +| `skill_ids_json` | string (JSON) | No | JSON array of skill ID strings | + +**`links_json` example value:** +```json +[{"label":"GitHub","url":"https://github.com/myorg/myrepo"},{"label":"Live Demo","url":"https://myapp.com"}] +``` + +**`skill_ids_json` example value:** +```json +["skill-uuid-1","skill-uuid-2"] +``` + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "Project": { "id": "...", "title": "My Project", "...": "..." } + } +} +``` + +--- + +## Endpoint: `/` + +### `GET /` +Retrieve a single project by ID. + +**Path params:** `pk` — project UUID + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "Project": { "id": "...", "title": "My Project", "...": "..." } + } +} +``` + +--- + +### `PUT /` +Update a project. **Auth required.** Partial updates are supported. +Request must be `multipart/form-data`. + +**Path params:** `pk` — project UUID + +**Form fields:** same as `POST /`, all fields optional. Submitting `images` replaces all existing images. Submitting `links_json` replaces all links. Submitting `skill_ids_json` replaces all skill tags. + +**Response (success):** same shape as `POST /`. + +--- + +### `DELETE /` +Delete a project. **Auth required** (must be project owner). + +**Path params:** `pk` — project UUID + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Project deleted successfully"] }, + "response": {} +} +``` + +--- + +## Endpoint: `/status/` + +### `PATCH /status/` +Change a project's publication status. **Auth required** (must be project owner). + +**Path params:** `pk` — project UUID + +**Request body (JSON):** +```json +{ + "status": "archived" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `status` | `draft` \| `published` \| `archived` | New status | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "Project": { "id": "...", "status": "archived", "...": "..." } + } +} +``` + +--- + +## Endpoint: `/members/` + +### `GET /members/` +List all team members of a project. **Auth required.** + +**Path params:** `project_id` — project UUID + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "Members": [ + { + "id": "...", + "is_linked": true, + "user_id": "...", + "muid": "MU-1234", + "full_name": "Alice", + "profile_pic": null, + "external_name": null, + "role": "Backend", + "created_at": "2025-01-01T00:00:00Z" + }, + { + "id": "...", + "is_linked": false, + "user_id": null, + "muid": null, + "full_name": "Bob External", + "profile_pic": null, + "external_name": "Bob External", + "role": "Designer", + "created_at": "2025-01-02T00:00:00Z" + } + ] + } +} +``` + +--- + +### `POST /members/` +Add a team member to a project. **Auth required** (must be project owner). + +Provide exactly one of `muid`, `user_id`, or `external_name` to identify the member. + +**Path params:** `project_id` — project UUID + +**Request body (JSON):** +```json +{ "muid": "MU-5678", "role": "Frontend" } +``` +or +```json +{ "user_id": "550e8400-...", "role": "Backend" } +``` +or +```json +{ "external_name": "Jane Doe", "role": "Designer" } +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `muid` | string | One of three | Mulearn ID of an existing user | +| `user_id` | string (UUID) | One of three | Internal user ID | +| `external_name` | string | One of three | Free-text name for non-mulearn contributors (max 100 chars) | +| `role` | string | No | Member's role on the project (max 50 chars) | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "Member": { + "id": "...", "is_linked": true, "user_id": "...", + "muid": "MU-5678", "full_name": "Alice", "profile_pic": null, + "external_name": null, "role": "Frontend", "created_at": "..." + } + } +} +``` + +--- + +## Endpoint: `/members//` + +### `DELETE /members//` +Remove a team member from a project. **Auth required** (must be project owner). + +**Path params:** +- `project_id` — project UUID +- `pk` — member UUID + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Member removed"] }, + "response": {} +} +``` + +--- + +## Endpoint: `vote/` + +### `POST vote/` +Cast or update a vote on a project (upsert — one vote per user per project). **Auth required.** + +**Request body (JSON):** +```json +{ + "vote": "upvote", + "project": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `vote` | `upvote` \| `downvote` | Vote type | +| `project` | string (UUID) | Project to vote on | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "Vote": { + "id": "...", "vote": "upvote", "project": "...", + "user": "Alice", "user_id": "...", + "created_at": "...", "updated_at": "..." + } + } +} +``` + +--- + +## Endpoint: `vote//` + +### `DELETE vote//` +Remove a vote. **Auth required** (must be vote owner). + +**Path params:** `pk` — vote UUID + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Vote deleted successfully"] }, + "response": {} +} +``` + +--- + +## Endpoint: `comment/` + +### `POST comment/` +Post a comment on a project. **Auth required.** + +**Request body (JSON):** +```json +{ + "comment": "This is a great project!", + "project": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `comment` | string | Comment text | +| `project` | string (UUID) | Project to comment on | + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "Comment": { + "id": "...", "comment": "This is a great project!", "project": "...", + "user": "Alice", "user_id": "...", + "created_at": "...", "updated_at": "..." + } + } +} +``` + +--- + +## Endpoint: `comment//` + +### `PUT comment//` +Edit a comment. **Auth required** (must be comment owner). + +**Path params:** `pk` — comment UUID + +**Request body (JSON):** +```json +{ "comment": "Updated comment text." } +``` + +**Response (success):** same shape as `POST comment/`. + +--- + +### `DELETE comment//` +Delete a comment. **Auth required** (must be comment owner). + +**Path params:** `pk` — comment UUID + +**Response (success):** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["comment deleted successfully"] }, + "response": {} +} +``` diff --git a/api_docs/Dashboard_Referral.md b/api_docs/Dashboard_Referral.md new file mode 100644 index 000000000..8d77a1d89 --- /dev/null +++ b/api_docs/Dashboard_Referral.md @@ -0,0 +1,31 @@ +# Dashboard / Referral + + +Base path: `/api/dashboard/referral/` + + +## Endpoint: `send-referral/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Roles.md b/api_docs/Dashboard_Roles.md new file mode 100644 index 000000000..1d668576c --- /dev/null +++ b/api_docs/Dashboard_Roles.md @@ -0,0 +1,341 @@ +# Dashboard / Roles + + +Base path: `/api/dashboard/roles/` + + +## Endpoint: `user-role//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:role_id` +- Request body example (JSON): +```json +{ + "role_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `base-template/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `bulk-assign/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `bulk-assign//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:role_id` +- Request body example (JSON): +```json +{ + "role_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `bulk-assign-excel/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-role/` +- Brief: Assign a role to a single user and provision any required downstream resources. +- Headers required: + - `Authorization: Bearer ` +- Request body examples: + - General role assignment: + ```json + { + "user_id": "", + "role_id": "" + } + ``` + - Intern role assignment: + ```json + { + "user_id": "", + "role_id": "", + "guild": "Backend Guild" + } + ``` + - Mentor role assignment (General mentor): + ```json + { + "user_id": "", + "role_id": "", + "mentor_tier": "MENTOR" + } + ``` + - Mentor role assignment (IG mentor): + ```json + { + "user_id": "", + "role_id": "", + "mentor_tier": "IG_MENTOR", + "ig_ids": ["", ""] + } + ``` + - Mentor role assignment (Campus/Company mentor): + ```json + { + "user_id": "", + "role_id": "", + "mentor_tier": "CAMPUS_MENTOR", + "org_id": "" + } + ``` +- Notes: + - `guild` is required when `role_id` corresponds to the Intern role. + - `mentor_tier` is required when `role_id` corresponds to the Mentor role. + - `ig_ids` is required when `mentor_tier` is `IG_MENTOR`. + - `org_id` is required when `mentor_tier` is `CAMPUS_MENTOR` or `COMPANY_MENTOR`. +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": ["Role Added Successfully"] + }, + "response": { + "message": "Role Added Successfully" + } +} +``` +- Extended success response examples: + - Intern role assignment: + ```json + { + "hasError": false, + "statusCode": 200, + "message": { + "general": ["Role Added Successfully"] + }, + "response": { + "message": "Role Added Successfully", + "intern_guild_created": true + } + } + ``` + - Mentor role assignment: + ```json + { + "hasError": false, + "statusCode": 200, + "message": { + "general": ["Role Added Successfully"] + }, + "response": { + "message": "Role Added Successfully", + "mentor_profile_created": true + } + } + ``` +- Error examples: + - Missing guild for Intern: + ```json + { + "hasError": true, + "statusCode": 400, + "message": { + "guild": ["guild is required when assigning the Intern role."] + } + } + ``` + - Missing org_id for Campus/Company Mentor: + ```json + { + "hasError": true, + "statusCode": 400, + "message": { + "org_id": ["org_id is required for CAMPUS_MENTOR."] + } + } + ``` + + +## Endpoint: `csv/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:roles_id` +- Request body example (JSON): +```json +{ + "roles_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:roles_id` +- Request body example (JSON): +```json +{ + "roles_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Task.md b/api_docs/Dashboard_Task.md new file mode 100644 index 000000000..9342c3054 --- /dev/null +++ b/api_docs/Dashboard_Task.md @@ -0,0 +1,349 @@ +# Dashboard / Task + + +Base path: `/api/dashboard/task/` + + +## Endpoint: `list-task-type/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `task-type//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:task_type_id` +- Request body example (JSON): +```json +{ + "task_type_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `channel/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `ig/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `organization/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `level/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `task-types/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `csv/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `import/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `base-template/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `events/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:task_id` +- Request body example (JSON): +```json +{ + "task_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Task_Report.md b/api_docs/Dashboard_Task_Report.md new file mode 100644 index 000000000..f6cc4e393 --- /dev/null +++ b/api_docs/Dashboard_Task_Report.md @@ -0,0 +1,111 @@ +# Dashboard / Task_Report + + +Base path: `/api/dashboard/task_report/` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:report_id` +- Request body example (JSON): +```json +{ + "report_id": "", + "status": "PENDING", + "updated_by": "Moderator Name (optional)" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": { + "id": "", + "status": "RESOLVED" + } + } +} +``` + +### Update (PUT) +- Method: PUT +- Path: `/api/dashboard/task_report//` +- Brief: Update a task report's `status`. Requires role Admin or Fellow. +- Full request body (all params accepted): + +```json +{ + "status": "RESOLVED", + "updated_by": "Moderator Name (optional)" +} +``` + +- Success (200): + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Report status updated successfully"] }, + "response": {} +} +``` + +- Failure (not found) example (400): + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { "general": ["Report not found"] }, + "response": {} +} +``` + +- Failure (validation) example (400): + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "status": ["This field is required."] + }, + "response": {} +} +``` + + +## Endpoint: `group-by-reporter/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_User.md b/api_docs/Dashboard_User.md new file mode 100644 index 000000000..8980bf569 --- /dev/null +++ b/api_docs/Dashboard_User.md @@ -0,0 +1,468 @@ +# Dashboard / User + + +Base path: `/api/dashboard/user/` + + +## Endpoint: `preferences/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `search/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verification/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verification/csv/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verification//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:link_id` +- Request body example (JSON): +```json +{ + "link_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verification//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:link_id` +- Request body example (JSON): +```json +{ + "link_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `organization/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `organization/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `info/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `forgot-password/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `reset-password/verify-token//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:token` +- Request body example (JSON): +```json +{ + "token": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `reset-password//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:token` +- Request body example (JSON): +```json +{ + "token": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `profile/update/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `csv/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:user_id` +- Request body example (JSON): +```json +{ + "user_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:user_id` +- Request body example (JSON): +```json +{ + "user_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:user_id` +- Request body example (JSON): +```json +{ + "user_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Dashboard_Utilities.md b/api_docs/Dashboard_Utilities.md new file mode 100644 index 000000000..d35a037c3 --- /dev/null +++ b/api_docs/Dashboard_Utilities.md @@ -0,0 +1,430 @@ +# Dashboard Utilities & Extras API Reference + +## Channels Management + +### List Channels +**Endpoint:** `/api/v1/dashboard/channels/` +**Method:** `GET` +**Brief:** List all channels. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Channels Fetched Successfully" + ] + }, + "response": { + "data": [ + { + "id": "uuid", + "name": "Channel Name", + "discord_id": "123456", + "created_by": "User", + "updated_by": "User", + "created_at": "date", + "updated_at": "date" + } + ], + "pagination": {} + } +} +``` + +### Create Channel +**Endpoint:** `/api/v1/dashboard/channels/` +**Method:** `POST` +**Brief:** Create a new channel. +**Permissions:** Admin +**Request Body:** +```json +{ + "name": "New Channel", + "discord_id": "123456" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Channel Created Successfully" + ] + }, + "response": { + "id": "uuid", + "name": "New Channel", + "discord_id": "123456" + } +} +``` + +### Edit Channel +**Endpoint:** `/api/v1/dashboard/channels//` +**Method:** `PUT` +**Brief:** Edit an existing channel. +**Permissions:** Admin +**Request Body:** +```json +{ + "name": "Updated Name", + "discord_id": "654321" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Channel Updated Successfully" + ] + }, + "response": {} +} +``` + +### Delete Channel +**Endpoint:** `/api/v1/dashboard/channels//` +**Method:** `DELETE` +**Brief:** Delete a channel. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Channel Deleted Successfully" + ] + }, + "response": {} +} +``` + +## Discord Moderator + +### Moderator Leaderboard +**Endpoint:** `/api/v1/dashboard/discord-moderator/leaderboard/` +**Method:** `GET` +**Brief:** Get discord moderator leaderboard. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Leaderboard Fetched Successfully" + ] + }, + "response": [ + { + "muid": "user@mulearn", + "full_name": "Full Name", + "task_count": 10 + } + ] +} +``` + +### Pending Tasks +**Endpoint:** `/api/v1/dashboard/discord-moderator/pending-tasks/` +**Method:** `GET` +**Brief:** Get count of pending tasks for moderator. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Pending Tasks Fetched Successfully" + ] + }, + "response": { + "count": 5 + } +} +``` + +### Task List +**Endpoint:** `/api/v1/dashboard/discord-moderator/tasks/` +**Method:** `GET` +**Brief:** List tasks for moderation. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Tasks Fetched Successfully" + ] + }, + "response": { + "data": [ + { + "user_id": "user_id", + "fullname": "Full Name", + "task_name": "Task Title", + "discord_link": "link", + "status": "pending", + "level": "level", + "karma": 100, + "created_at": "date" + } + ], + "pagination": {} + } +} +``` + +## Events Management + +### List Events +**Endpoint:** `/api/v1/dashboard/events/` +**Method:** `GET` +**Brief:** List all events. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Events Fetched Successfully" + ] + }, + "response": { + "data": [ + { + "id": "uuid", + "name": "Event Name", + "description": "Description", + "created_by": "User", + "updated_by": "User", + "created_at": "date", + "updated_at": "date" + } + ], + "pagination": {} + } +} +``` + +### Create Event +**Endpoint:** `/api/v1/dashboard/events/` +**Method:** `POST` +**Brief:** Create a new event. +**Permissions:** Admin +**Request Body:** +```json +{ + "name": "New Event", + "description": "Event Description" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Event Created Successfully" + ] + }, + "response": { + "id": "uuid", + "name": "New Event", + "description": "Event Description" + } +} +``` + +### Edit Event +**Endpoint:** `/api/v1/dashboard/events//` +**Method:** `PUT` +**Brief:** Edit an existing event. +**Permissions:** Admin +**Request Body:** +```json +{ + "name": "Updated Event Name", + "description": "Updated Description" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Event Updated Successfully" + ] + }, + "response": {} +} +``` + +### Delete Event +**Endpoint:** `/api/v1/dashboard/events//` +**Method:** `DELETE` +**Brief:** Delete an event. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Event Deleted Successfully" + ] + }, + "response": {} +} +``` + +## Coupon Management + +### Verify Coupon +**Endpoint:** `/api/v1/dashboard/coupon/verify/` +**Method:** `POST` +**Brief:** Verify a coupon code. +**Permissions:** Admin +**Request Body:** +```json +{ + "code": "COUPON123" +} +``` +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Coupon Verified" + ] + }, + "response": { + "id": "uuid", + "code": "COUPON123", + "discount": 10 + } +} +``` + +## Projects Management + +### List Projects +**Endpoint:** `/api/v1/dashboard/projects/` +**Method:** `GET` +**Brief:** List all projects. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Projects Fetched Successfully" + ] + }, + "response": [ + { + "id": "uuid", + "title": "Project Title", + "description": "Description", + "link": "url" + } + ] +} +``` + +## Achievement Management + +### List Achievements +**Endpoint:** `/api/v1/dashboard/achievement/` +**Method:** `GET` +**Brief:** List achievements. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Achievements Fetched Successfully" + ] + }, + "response": [ + { + "id": "uuid", + "title": "Achievement Title", + "description": "desc" + } + ] +} +``` + +### Create Achievement +**Endpoint:** `/api/v1/dashboard/achievement/` +**Method:** `POST` +**Brief:** Create achievement. +**Permissions:** Admin +**Request Body:** +```json +{ + "title": "Title", + "description": "Description" +} +``` + +## Task Report + +### List Reports +**Endpoint:** `/api/v1/dashboard/task-report/` +**Method:** `GET` +**Brief:** List task reports. +**Permissions:** Admin +**Sample Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Task Reports Fetched Successfully" + ] + }, + "response": { + "data": [ + { + "id": "uuid", + "task_id": "task_uuid", + "report": "Report Content", + "created_by": "User" + } + ], + "pagination": {} + } +} +``` diff --git a/api_docs/Dashboard_Zonal.md b/api_docs/Dashboard_Zonal.md new file mode 100644 index 000000000..eef3fa4e4 --- /dev/null +++ b/api_docs/Dashboard_Zonal.md @@ -0,0 +1,318 @@ +# Dashboard — Zonal API + +**Base path:** `/api/dashboard/zonal/` +**Authentication:** JWT Bearer Token required on all endpoints. + +--- + +## Table of Contents + +| # | Endpoint | Method | Auth / Role | +|---|----------|--------|-------------| +| 1 | [`zonal-details/`](#1-zonal-details) | `GET` | Zonal Campus Lead | +| 2 | [`top-districts/`](#2-top-districts) | `GET` | Zonal Campus Lead | +| 3 | [`student-level/`](#3-student-level) | `GET` | Zonal Campus Lead | +| 4 | [`student-details/`](#4-student-details) | `GET` | Zonal Campus Lead | +| 5 | [`student-details/csv/`](#5-student-detailscsv) | `GET` | Zonal Campus Lead | +| 6 | [`college-details/`](#6-college-details) | `GET` | Zonal Campus Lead | +| 7 | [`college-details/csv/`](#7-college-detailscsv) | `GET` | Zonal Campus Lead | + +--- + +## 1. Zonal Details + +**`GET /api/dashboard/zonal/zonal-details/`** + +Returns detailed information about the authenticated Zonal Campus Lead's zone, including rank, lead name, karma stats, and membership counts. + +**Roles:** `Zonal Campus Lead` + +**Request Body:** None + +**Query Params:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "zone": "Central Zone", + "rank": 1, + "zonal_lead": "Priya Nair", + "karma": 385000, + "total_members": 4200, + "active_members": 1850 + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `zone` | string | Name of the zone | +| `rank` | integer | Zone rank across all zones (by total karma) | +| `zonal_lead` | string | Full name of the authenticated Zonal Campus Lead | +| `karma` | integer | Total karma earned by all college members in the zone | +| `total_members` | integer | Total number of college org links in the zone | +| `active_members` | integer | Members with karma activity in the current month | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 2. Top Districts + +**`GET /api/dashboard/zonal/top-districts/`** + +Returns the top 3 districts in the zone by total karma. + +**Roles:** `Zonal Campus Lead` + +**Request Body:** None + +**Query Params:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": [ + { + "district": "Ernakulam", + "rank": 1, + "karma": 128500 + }, + { + "district": "Thrissur", + "rank": 2, + "karma": 105200 + }, + { + "district": "Kottayam", + "rank": 3, + "karma": 87300 + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `district` | string | Name of the district | +| `rank` | integer | Rank of the district within the zone | +| `karma` | integer | Total karma of all verified college members in the district | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 3. Student Level + +**`GET /api/dashboard/zonal/student-level/`** + +Returns the count of students at each karma level across all colleges in the zone. + +**Roles:** `Zonal Campus Lead` + +**Request Body:** None + +**Query Params:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": [ + { "level_order": 1, "students_count": 1800 }, + { "level_order": 2, "students_count": 980 }, + { "level_order": 3, "students_count": 520 }, + { "level_order": 4, "students_count": 190 }, + { "level_order": 5, "students_count": 35 } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `level_order` | integer | The level number | +| `students_count` | integer | Number of distinct students at this level in the zone | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 4. Student Details + +**`GET /api/dashboard/zonal/student-details/`** + +Returns a paginated list of all students across colleges in the zone with their karma, level, and rank. + +**Roles:** `Zonal Campus Lead` + +**Query Params:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `pageIndex` | integer | No | Page number (default: 1) | +| `perPage` | integer | No | Items per page (default: 20) | +| `sortBy` | string | No | Field to sort by (`full_name`, `muid`, `karma`, `level`) | +| `search` | string | No | Search by full name or level | + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "user_id": "a1b2c3d4-...", + "full_name": "Rahul Krishna", + "muid": "rahul-krishna@mulearn", + "karma": 3400, + "rank": 1, + "level": "Pathfinder" + } + ], + "pagination": { + "count": 4200, + "totalPages": 210, + "isFirst": true, + "isLast": false, + "nextPage": "/student-details/?pageIndex=2" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `user_id` | string (UUID) | Unique user ID | +| `full_name` | string | Student's full name | +| `muid` | string | muID of the student | +| `karma` | integer | Total karma points | +| `rank` | integer | Rank within the zone | +| `level` | string | Current karma level name | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 5. Student Details CSV + +**`GET /api/dashboard/zonal/student-details/csv/`** + +Downloads the same student data as endpoint 4 as a CSV file (no pagination — exports all records). + +**Roles:** `Zonal Campus Lead` + +**Request Body:** None + +**Response:** `text/csv` file download — `Zonal Student Details.csv` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 6. College Details + +**`GET /api/dashboard/zonal/college-details/`** + +Returns a paginated list of all colleges in the zone, including their campus code, level, and campus lead information. + +**Roles:** `Zonal Campus Lead` + +**Query Params:** + +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `pageIndex` | integer | No | Page number (default: 1) | +| `perPage` | integer | No | Items per page (default: 20) | +| `sortBy` | string | No | Field to sort by (`title`, `code`) | +| `search` | string | No | Search by college title or code | + +**Request Body:** None + +**Response:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success"] }, + "response": { + "data": [ + { + "id": "82e6f245-59ea-4be4-a33d-35189a395000", + "title": "Example College of Engineering", + "code": "ECE001", + "level": 3, + "lead": "Arjun Menon", + "lead_number": "9876543210" + } + ], + "pagination": { + "count": 120, + "totalPages": 6, + "isFirst": true, + "isLast": false, + "nextPage": "/college-details/?pageIndex=2" + } + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string (UUID) | Unique organization ID of the college | +| `title` | string | Name of the college | +| `code` | string | Campus code | +| `level` | integer \| null | College's campus level | +| `lead` | string \| null | Full name of the college's Campus Lead | +| `lead_number` | string \| null | Mobile number of the Campus Lead | + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` + +--- + +## 7. College Details CSV + +**`GET /api/dashboard/zonal/college-details/csv/`** + +Downloads the same college data as endpoint 6 as a CSV file (no pagination — exports all records). + +**Roles:** `Zonal Campus Lead` + +**Request Body:** None + +**Response:** `text/csv` file download — `Zonal College Details.csv` + +**Error Responses:** +```json +{ "hasError": true, "statusCode": 400, "message": { "general": ["No college organization linked to this user."] } } +``` diff --git a/api_docs/Donate.md b/api_docs/Donate.md new file mode 100644 index 000000000..36cecb311 --- /dev/null +++ b/api_docs/Donate.md @@ -0,0 +1,135 @@ +# Donate + + +Base path: `/api/donate/` + + +## Endpoint: `order/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verify/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `subscription/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `subscription/verify/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `bank-transfer/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Hackathon.md b/api_docs/Hackathon.md new file mode 100644 index 000000000..62909f9fa --- /dev/null +++ b/api_docs/Hackathon.md @@ -0,0 +1,503 @@ +# Hackathon + + +Base path: `/api/hackathon/` + + +## Endpoint: `list-hackathons/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-hackathons/upcoming/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-hackathons//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `info//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `create-hackathon/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `edit-hackathon//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `delete-hackathon//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `publish-hackathon//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `submit-hackathon/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-organiser-hackathons//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `add-organiser//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `delete-organiser//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:organiser_link_id` +- Request body example (JSON): +```json +{ + "organiser_link_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-applicants/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-applicants//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-form//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:hackathon_id` +- Request body example (JSON): +```json +{ + "hackathon_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-organisations/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-districts/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-default-form-fields/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Integrations.md b/api_docs/Integrations.md new file mode 100644 index 000000000..226b0562c --- /dev/null +++ b/api_docs/Integrations.md @@ -0,0 +1,83 @@ +# Integrations + + +Base path: `/api/integrations/` + + +## Endpoint: `kkem/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `wadhwani/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `qseverse/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Integrations_Kkem.md b/api_docs/Integrations_Kkem.md new file mode 100644 index 000000000..ed234168f --- /dev/null +++ b/api_docs/Integrations_Kkem.md @@ -0,0 +1,225 @@ +# Integrations / Kkem + + +Base path: `/api/integrations/kkem/` + + +## Endpoint: `login/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `authorization/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `authorization//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:token` +- Request body example (JSON): +```json +{ + "token": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user/status//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:encrypted_data` +- Request body example (JSON): +```json +{ + "encrypted_data": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:encrypted_data` +- Request body example (JSON): +```json +{ + "encrypted_data": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `users/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `users//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:muid` +- Request body example (JSON): +```json +{ + "muid": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `hackathon-stats/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Integrations_Qseverse.md b/api_docs/Integrations_Qseverse.md new file mode 100644 index 000000000..c73f2a41e --- /dev/null +++ b/api_docs/Integrations_Qseverse.md @@ -0,0 +1,109 @@ +# Integrations / Qseverse + + +Base path: `/api/integrations/qseverse/` + + +## Endpoint: `issue-vc/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `connected-users/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `connected-users/search` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `qs-credentials/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Integrations_Wadhwani.md b/api_docs/Integrations_Wadhwani.md new file mode 100644 index 000000000..dea417def --- /dev/null +++ b/api_docs/Integrations_Wadhwani.md @@ -0,0 +1,135 @@ +# Integrations / Wadhwani + + +Base path: `/api/integrations/wadhwani/` + + +## Endpoint: `auth-token/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-login/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `course-details/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `course-enroll-status/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `course-quiz-data/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Launchpad.md b/api_docs/Launchpad.md new file mode 100644 index 000000000..c9b774878 --- /dev/null +++ b/api_docs/Launchpad.md @@ -0,0 +1,1202 @@ +# Launchpad + + +Base path: `/api/launchpad/` + + +## Endpoint: `register-company/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `register-recruiter/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `company-list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `company-list-verified/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `login-company/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `login-recruiter/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `refresh-token/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `add-job/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `job//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:job_id` +- Request body example (JSON): +```json +{ + "job_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `company-info/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `recruiter-info/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `company-verify/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-jobs/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verify-task/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-launchpad-students//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:job_id` +- Request body example (JSON): +```json +{ + "job_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `hire-requests/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `send-job-invitations/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `student/job-invitations/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `student/apply-to-job/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `accepted-students/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `accepted-students//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:job_id` +- Request body example (JSON): +```json +{ + "job_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `schedule-interview/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `application-final-decision/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `delete-company/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `leaderboard/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `task-completed-leaderboard/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-participants/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `launchpad-details/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `college-data/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-college-link/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-college-link/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:email` +- Request body example (JSON): +```json +{ + "email": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-college-link-public/` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:email` +- Request body example (JSON): +```json +{ + "email": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-profile/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-college-data/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `bulk-user-college-link/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list-participants-admin/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-details//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:launchpad_id` +- Request body example (JSON): +```json +{ + "launchpad_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `socials//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:launchpad_id` +- Request body example (JSON): +```json +{ + "launchpad_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-log//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:launchpad_id` +- Request body example (JSON): +```json +{ + "launchpad_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `get-user-levels//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:launchpad_id` +- Request body example (JSON): +```json +{ + "launchpad_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `ig-leaderboard/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `forgot-password/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `reset-password/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `verify-reset-token/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `change-password/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Leaderboard.md b/api_docs/Leaderboard.md new file mode 100644 index 000000000..175dbf400 --- /dev/null +++ b/api_docs/Leaderboard.md @@ -0,0 +1,161 @@ +# Leaderboard + + +Base path: `/api/leaderboard/` + + +## Endpoint: `students/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `students-monthly/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `college/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `college-monthly/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `wadhwani-college/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `wadhwani-zonal/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Protected.md b/api_docs/Protected.md new file mode 100644 index 000000000..99f1f3bc5 --- /dev/null +++ b/api_docs/Protected.md @@ -0,0 +1,31 @@ +# Protected + + +Base path: `/api/protected/` + + +## Endpoint: `organisation/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Protected_Organisation.md b/api_docs/Protected_Organisation.md new file mode 100644 index 000000000..1087e65fc --- /dev/null +++ b/api_docs/Protected_Organisation.md @@ -0,0 +1,65 @@ +# Protected / Organisation + + +Base path: `/api/protected/organisation/` + + +## Endpoint: `institutes///` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:organisation_type` + - `str:district_name` +- Request body example (JSON): +```json +{ + "organisation_type": "", + "district_name": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `get-institutes//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:district_name` +- Request body example (JSON): +```json +{ + "district_name": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Register.md b/api_docs/Register.md new file mode 100644 index 000000000..2b44fd47a --- /dev/null +++ b/api_docs/Register.md @@ -0,0 +1,603 @@ +# Register + + +Base path: `/api/register/` + + +## Endpoint: `validate/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `role/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `colleges/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `department/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `location/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `country/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `state/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `district/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `college/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `company/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `community/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `schools/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `area-of-interest/list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `lc/user-validation/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `email-verification/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-country/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-state/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `user-zone/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `interests/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `select-domains/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `select-endgoals/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `connect-discord/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `organization/create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Root.md b/api_docs/Root.md new file mode 100644 index 000000000..b7f33cd37 --- /dev/null +++ b/api_docs/Root.md @@ -0,0 +1,369 @@ +# API + + +Base path: `/api/` + + +## Endpoint: `auth/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `register/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `leaderboard/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `dashboard/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `integrations/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `url-shortener/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `protected/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `hackathon/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `notification/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `public/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `top100/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `launchpad/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `donate/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `__debug__/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/Signup_APIs_Company_Enabler_Mentor.md b/api_docs/Signup_APIs_Company_Enabler_Mentor.md new file mode 100644 index 000000000..9303b1b95 --- /dev/null +++ b/api_docs/Signup_APIs_Company_Enabler_Mentor.md @@ -0,0 +1,567 @@ +# Signup APIs: Company, Enabler, Mentor + +Base URL: `/api/v1` + +This document is based on the current code paths in: +- `api/dashboard/company/onboarding/*` +- `api/register/*` +- `api/dashboard/user/*` + +## Important implementation note + +There is a dedicated signup API for `company`. + +There is currently **no separate public `mentor signup` API** or **`enabler signup` API** controller in the codebase. Both `mentor` and `enabler` signups are implemented through the generic user registration endpoint: + +- `POST /api/v1/register/` + +The requested role is attached during registration, and that role starts as: +- `verified = true` for `Student` and `Mulearner` +- `verified = false` for roles such as `Mentor` and `Enabler` + +That behavior is implemented in `api/register/serializers.py`. + +## Common response envelope + +All endpoints documented here use the shared `CustomResponse` format: + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": {} +} +``` + +Failure responses follow the same shape with `hasError: true`. + +--- + +## 1. Company Signup + +### POST `/api/v1/dashboard/company/create/` + +Creates: +- a new `user` for the point of contact +- a `company` role link with `verified = false` +- a `company` organization link with `verified = false` +- a `company` onboarding record with `status = pending_verification` + +### Authentication + +No authentication required. + +### Request body + +#### Required fields + +```json +{ + "name": "Acme Labs", + "poc_name": "Jane Doe", + "poc_email": "jane@acme.com", + "password": "StrongPass123" +} +``` + +#### Optional fields + +```json +{ + "poc_phone": "+919999999999", + "website_link": "https://acme.com", + "description": "Product engineering company", + "industry_sector": "Technology", + "location": "Kochi, Kerala", + "district_id": "district-uuid", + "legal_name": "Acme Labs Private Limited", + "registration_number": "REG-12345", + "tax_id": "GSTIN-12345", + "company_size": "51-200", + "linkedin_url": "https://linkedin.com/company/acme", + "verification_document_url": "https://example.com/proof.pdf" +} +``` + +### Validation rules + +- `name`: required, trimmed, max 75 chars, cannot be empty. +- `poc_name`: required, trimmed, max 150 chars, cannot be empty. +- `poc_email`: required, lowercased, must be unique across users. +- `password`: required, min length `8`. +- `poc_phone`: optional, must match `^\+?[0-9]{8,15}$` if provided, must be unique across users. +- `district_id`: optional only when a matching company organization already exists. +- If no existing `Organization` of type `Company` exists with the same title, `district_id` becomes required. +- If a `Company` record already exists with the same name, signup fails with conflict. + +### Success response + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Company registration submitted successfully" + ] + }, + "response": { + "company_id": "uuid", + "slug": "acme-labs", + "muid": "jane-doe@mulearn", + "status": "pending_verification", + "auth": { + "access": "...", + "refresh": "...", + "data": { + "id": "user-uuid", + "muid": "jane-doe@mulearn", + "email": "jane@acme.com", + "role": null, + "full_name": "Jane Doe" + } + } + } +} +``` + +### Notes + +- The nested `auth` object comes from `get_auth_token(...)`. +- `response.auth.data.role` is usually `null` here because `UserDetailSerializer` only surfaces `Mentor` or `Enabler`, not `Company`. +- If no matching company organization exists, the API creates one automatically using the provided `district_id`. + +### Error cases + +#### 400 Validation error + +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": [ + "Invalid company signup data" + ], + "error_code": "VALIDATION_ERROR", + "errors": { + "district_id": [ + "district_id is required when no matching company organization exists" + ] + } + }, + "response": {} +} +``` + +#### 409 Duplicate email + +```json +{ + "hasError": true, + "statusCode": 409, + "message": { + "general": [ + "A user with this email already exists" + ], + "error_code": "DUPLICATE_POC_EMAIL", + "errors": { + "poc_email": [ + "A user with this email already exists" + ] + } + }, + "response": {} +} +``` + +#### 409 Duplicate company name + +```json +{ + "hasError": true, + "statusCode": 409, + "message": { + "general": [ + "Company name already exists" + ], + "error_code": "DUPLICATE_COMPANY_NAME", + "errors": { + "name": [ + "Company name already exists" + ] + } + }, + "response": {} +} +``` + +#### 409 Duplicate phone + +```json +{ + "hasError": true, + "statusCode": 409, + "message": { + "general": [ + "A user with this phone number already exists" + ], + "error_code": "DUPLICATE_POC_PHONE", + "errors": { + "poc_phone": [ + "A user with this phone number already exists" + ] + } + }, + "response": {} +} +``` + +--- + +## 2. Company Signup Follow-up APIs + +These are part of the company signup lifecycle. + +### GET `/api/v1/dashboard/company/onboarding/status/` + +Returns the current company onboarding state for the authenticated company user. + +### Authentication + +Bearer token required. + +### Success response shape + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Company onboarding status fetched successfully" + ] + }, + "response": { + "id": "company-uuid", + "name": "Acme Labs", + "slug": "acme-labs", + "status": "pending_verification", + "poc_name": "Jane Doe", + "poc_email": "jane@acme.com", + "rejection_reason": null, + "verification_requested_at": "2026-04-05T10:00:00+05:30", + "verified_at": null, + "created_at": "2026-04-05T10:00:00+05:30", + "updated_at": "2026-04-05T10:00:00+05:30", + "can_edit_profile": true, + "can_access_advanced_features": false, + "next_steps": [ + "Wait for admin verification approval" + ] + } +} +``` + +### POST `/api/v1/dashboard/company/verification/resubmit/` + +Allows an authenticated company user to resubmit a previously rejected company verification request. + +### Authentication + +Bearer token required. + +### Success response + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Company verification request resubmitted successfully" + ] + }, + "response": { + "company_id": "company-uuid", + "status": "pending_verification", + "verification_requested_at": "2026-04-05T10:00:00+05:30" + } +} +``` + +--- + +## 3. Mentor Signup + +## Actual implementation in this codebase + +Mentor signup is handled through: + +- `POST /api/v1/register/` + +There is no separate public endpoint like `/mentor/signup/`. + +### POST `/api/v1/register/` + +Registers a new user and optionally assigns a role during signup. + +### Authentication + +No authentication required. + +### Request body for mentor signup + +```json +{ + "user": { + "full_name": "Alex Mentor", + "email": "alex@example.com", + "password": "StrongPass123", + "dob": "1998-08-17", + "gender": "Male", + "role": "mentor-role-id", + "district": "district-uuid", + "area_of_interest": [ + "ig-uuid-1", + "ig-uuid-2" + ] + } +} +``` + +### Optional nested objects + +```json +{ + "integration": { + "title": "DWMS", + "param": "encrypted-value" + }, + "referral": { + "muid": "referrer@mulearn" + } +} +``` + +### What happens internally + +- A new `user` is created. +- `Wallet`, `Socials`, `UserSettings`, and default level link records are created. +- A `UserRoleLink` is created for the requested role. +- Since mentor is not `Student` or `Mulearner`, the role is created with `verified = false`. + +### Success response + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "access": "...", + "refresh": "...", + "data": { + "id": "user-uuid", + "muid": "alex-mentor@mulearn", + "email": "alex@example.com", + "role": "Mentor", + "full_name": "Alex Mentor" + } + } +} +``` + +### Important notes + +- The role value must be the DB primary key of the `Mentor` role, not the string `"Mentor"`. +- Use `GET /api/v1/register/role/list/` to fetch role ids. +- The codebase includes a `MentorSerializer` for storing mentor-specific fields (`about`, `reason`, `hours`), but it is not wired into `POST /api/v1/register/` or exposed by a dedicated public signup view right now. +- Actual approval of mentor requests happens later through admin verification. + +### Admin verification endpoint used after signup + +#### PATCH `/api/v1/dashboard/user/verification/{link_id}/` + +Marks a pending user-role link as verified or unverified. + +Example body: + +```json +{ + "verified": true +} +``` + +--- + +## 4. Enabler Signup + +## Actual implementation in this codebase + +Enabler signup is handled through: + +- `POST /api/v1/register/` + +There is no separate public endpoint like `/enabler/signup/`. + +### POST `/api/v1/register/` + +### Request body for enabler signup + +```json +{ + "user": { + "full_name": "Ena Builder", + "email": "ena@example.com", + "password": "StrongPass123", + "dob": "1999-02-20", + "gender": "Female", + "role": "enabler-role-id", + "district": "district-uuid", + "area_of_interest": [ + "ig-uuid-1" + ] + } +} +``` + +### What happens internally + +- A new `user` is created. +- The requested `Enabler` role is attached through `UserRoleLink`. +- Since `Enabler` is not one of the auto-verified roles, the new role link is created with `verified = false`. + +### Success response + +Response shape is the same as mentor signup, with `response.data.role` typically resolving to `"Enabler"`. + +### Important notes + +- The role value must be the DB role id for `Enabler`. +- Use `GET /api/v1/register/role/list/` to resolve role ids. +- Verification is completed later through admin review on the user verification API. + +### Admin verification endpoint used after signup + +#### PATCH `/api/v1/dashboard/user/verification/{link_id}/` + +Example body: + +```json +{ + "verified": true +} +``` + +--- + +## 5. Shared helper APIs used by frontend signup flows + +### GET `/api/v1/register/role/list/` + +Returns available roles with DB ids. + +Example response: + +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [] + }, + "response": { + "roles": [ + { + "id": "role-uuid", + "title": "Mentor" + }, + { + "id": "role-uuid", + "title": "Enabler" + } + ] + } +} +``` + +### GET `/api/v1/register/company/list/` + +Returns company organizations. + +### GET `/api/v1/register/colleges/` + +Returns college organizations. + +### GET `/api/v1/register/department/list/` + +Returns departments. + +### GET `/api/v1/register/country/list/` + +Returns countries. + +### POST `/api/v1/register/state/list/` + +Input: + +```json +{ + "country": "country-uuid" +} +``` + +### POST `/api/v1/register/district/list/` + +Input: + +```json +{ + "state": "state-uuid" +} +``` + +### POST `/api/v1/register/college/list/` + +Input: + +```json +{ + "district": "district-uuid", + "search": "engineering" +} +``` + +### POST `/api/v1/register/email-verification/` + +Checks whether an email already exists. + +Input: + +```json +{ + "email": "user@example.com" +} +``` + +### GET `/api/v1/register/location/?q=kochi` + +Searches districts/locations for signup forms. + +--- + +## 6. Summary + +- `Company` has a dedicated signup endpoint: `POST /api/v1/dashboard/company/create/` +- `Mentor` signup uses the generic user registration endpoint: `POST /api/v1/register/` +- `Enabler` signup uses the generic user registration endpoint: `POST /api/v1/register/` +- `Mentor` and `Enabler` are not auto-verified during signup +- `Company` is also not auto-verified and moves through a dedicated onboarding verification flow diff --git a/api_docs/Top100Coders.md b/api_docs/Top100Coders.md new file mode 100644 index 000000000..14980d9c0 --- /dev/null +++ b/api_docs/Top100Coders.md @@ -0,0 +1,24 @@ +# Top 100 Coders API Reference + +## Leaderboard +**Endpoint:** `/api/v1/top-100/leaderboard/` +**Method:** `GET` +**Brief:** Get the Top 100 Coders leaderboard. + +**Response Body:** +```json +{ + "response": [ + { + "id": "uuid", + "full_name": "string", + "profile_pic": "url", + "total_karma": 0, + "org": "string", + "dis": "string", + "state": "string", + "time_": "datetime" + } + ] +} +``` diff --git a/api_docs/Top100_coders.md b/api_docs/Top100_coders.md new file mode 100644 index 000000000..03276f6e2 --- /dev/null +++ b/api_docs/Top100_coders.md @@ -0,0 +1,31 @@ +# Top100_Coders + + +Base path: `/api/top100_coders/` + + +## Endpoint: `leaderboard/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/UrlShortener.md b/api_docs/UrlShortener.md new file mode 100644 index 000000000..29f6ca9d6 --- /dev/null +++ b/api_docs/UrlShortener.md @@ -0,0 +1,107 @@ +# Url Shortener API Reference + +## Create Short URL +**Endpoint:** `/api/v1/url-shortener/create/` +**Method:** `POST` +**Brief:** Create a new shortened URL. + +**Request Body:** +```json +{ + "title": "string", + "long_url": "string", + "short_url": "string (optional)" +} +``` + +**Response Body:** +```json +{ + "general_message": "Url created successfully.", + "statusCode": 200 +} +``` + +## List Short URLs +**Endpoint:** `/api/v1/url-shortener/list/` +**Method:** `GET` +**Brief:** List all shortened URLs (paginated). +**Query Params:** `page`, `page_size`, `search` (title, short_url, long_url) + +**Response Body:** +```json +{ + "response": { + "data": [ + { + "id": "uuid", + "title": "string", + "long_url": "string", + "short_url": "string", + "created_at": "datetime" + } + ], + "pagination": { + "count": 0, + "total_pages": 0, + "is_next": false, + "is_prev": false, + "next_page": 0 + } + } +} +``` + +## Edit Short URL +**Endpoint:** `/api/v1/url-shortener/edit//` +**Method:** `PUT` +**Brief:** Edit an existing shortened URL. + +**Request Body:** +```json +{ + "title": "string", + "long_url": "string", + "short_url": "string" +} +``` + +## Delete Short URL +**Endpoint:** `/api/v1/url-shortener/delete//` +**Method:** `DELETE` +**Brief:** Delete a shortened URL. + +**Response Body:** +```json +{ + "general_message": "Url deleted successfully..", + "statusCode": 200 +} +``` + +## Get Analytics +**Endpoint:** `/api/v1/url-shortener/get-analytics//` +**Method:** `GET` +**Brief:** Get detailed analytics for a shortened URL. + +**Response Body:** +```json +{ + "response": { + "total_clicks": 0, + "created_on": "YYYY-MM-DD", + "browsers": { "chrome": 0 }, + "platforms": { "windows": 0 }, + "devices": { "desktop": 0 }, + "sources": { "direct": 0 }, + "ip_address": { "127.0.0.1": 0 }, + "city": { "kochi": 0 }, + "region": { "kerala": 0 }, + "countries": { "India": 0 }, + "time_based_data": { "all_time": [] }, + "long_url": "string", + "short_url": "string", + "title": "string" + } +} +``` diff --git a/api_docs/Url_shortener.md b/api_docs/Url_shortener.md new file mode 100644 index 000000000..852e8e743 --- /dev/null +++ b/api_docs/Url_shortener.md @@ -0,0 +1,144 @@ +# Url_Shortener + + +Base path: `/api/url_shortener/` + + +## Endpoint: `create/` +- Brief: Collection endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `edit//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:url_id` +- Request body example (JSON): +```json +{ + "url_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `list/` +- Brief: Retrieval/list endpoint. +- Request body example (JSON): +```json +{ + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `delete//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:url_id` +- Request body example (JSON): +```json +{ + "url_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + + +## Endpoint: `get-analytics//` +- Brief: Resource-specific endpoint (path param). +- Path params: + - `str:url_id` +- Request body example (JSON): +```json +{ + "url_id": "", + "field1": "value1", + "field2": "value2" +} +``` +- Response example (success): +```json +{ + "hasError": false, + "statusCode": 200, + "message": { + "general": [ + "Success" + ] + }, + "response": { + "data": "..." + } +} +``` + diff --git a/api_docs/media_content_api.md b/api_docs/media_content_api.md new file mode 100644 index 000000000..18e064cb3 --- /dev/null +++ b/api_docs/media_content_api.md @@ -0,0 +1,857 @@ +# Media Content API — Comprehensive Documentation + +> Base URL prefix: `/api/v1/dashboard/media-content/` + +This module exposes CRUD endpoints for three content types previously managed via CMS, now stored in a single `media_content` database table with a `content_type` discriminator. + +--- + +## Table of Contents + +- [Authentication & Roles](#authentication--roles) +- [Common Response Envelope](#common-response-envelope) +- [Common Query Parameters](#common-query-parameters-list-endpoints) +- [Office Hours](#-office-hours) +- [Salt Mango Tree](#-salt-mango-tree) +- [Inspiration Station Radio](#-inspiration-station-radio) +- [Error Reference](#error-reference) +- [Enum Reference](#enum-reference) + +--- + +## Authentication & Roles + +| Operation | Auth Required | Role Required | +|-----------|--------------|---------------| +| `GET` list / detail | ❌ Public | None | +| `POST` create | ✅ Bearer JWT | `admin` | +| `PATCH` update | ✅ Bearer JWT | `admin` | +| `DELETE` soft-delete | ✅ Bearer JWT | `admin` | + +**Header for write operations:** +``` +Authorization: Bearer +``` + +> **Note:** DELETE is a **soft delete** — `deleted_at` is set and the record is excluded from all future list/detail queries. Data is **not** removed from the database. + +--- + +## Common Response Envelope + +**Success:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Success message"] }, + "response": {} +} +``` + +**Paginated list:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [], + "pagination": { + "count": 42, + "totalPages": 5, + "isNext": true, + "isPrev": false, + "nextPage": 2 + } + } +} +``` + +**Error:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { "general": ["Error message"] }, + "response": {} +} +``` + +--- + +## Common Query Parameters (List Endpoints) + +| Parameter | Type | Description | +|-----------|------|-------------| +| `pageIndex` | integer | Page number (default: `1`) | +| `perPage` | integer | Items per page (default: `10`) | +| `search` | string | Case-insensitive text search across indexed fields | +| `sortBy` | string | Field to sort by. Prefix with `-` for descending. e.g. `-date` | +| `status` | `upcoming` / `ongoing` / `completed` | Filter by computed status based on date | +| `zone` | string | `north` / `central` / `south` — SMT & Inspiration Station only | + +--- + +## 📅 Office Hours + +**Content type discriminator:** `office_hours` +**Date input format:** `DD/MM/YYYY` | **Date output format:** `YYYY-MM-DD` + +### Field Reference + +| Field | Type | Required | Max Length | Notes | +|-------|------|----------|------------|-------| +| `title` | string | ✅ | 300 | Session title | +| `date` | string | ✅ | — | Must be `DD/MM/YYYY` | +| `performer` | string | ❌ | 200 | Speaker / host name | +| `designation` | string | ❌ | 200 | e.g. "Senior Developer" | +| `description` | string | ❌ | unlimited | Session description | +| `link` | URL | ❌ | 500 | Meeting or streaming link | +| `interest_groups` | string[] | ❌ | — | Array of IG slugs | +| `poster_thumbnail` | string | ❌ | 512 | Image URL | + +--- + +### `GET /api/v1/dashboard/media-content/office-hours/` + +List all active Office Hours sessions. **Public.** + +**Search fields:** `title`, `performer`, `description` + +**Example:** +``` +GET /api/v1/dashboard/media-content/office-hours/?status=upcoming&pageIndex=1&perPage=5 +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Intro to REST APIs with Django", + "performer": "Alice Thomas", + "designation": "Senior Developer", + "description": "A hands-on session covering DRF best practices.", + "date": "2025-08-15", + "link": "https://meet.google.com/xyz-abc", + "interest_groups": ["web-development", "ai"], + "poster_thumbnail": "https://cdn.example.com/poster1.jpg", + "status": "upcoming", + "created_at": "2025-06-27T06:30:00.000000Z", + "updated_at": "2025-06-27T06:30:00.000000Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +### `POST /api/v1/dashboard/media-content/office-hours/` + +Create an Office Hours session. **Admin only.** + +**Full request body:** +```json +{ + "title": "Introduction to Web3", + "date": "15/09/2025", + "performer": "Priya Nair", + "designation": "Blockchain Developer", + "description": "Explore decentralised applications and smart contracts.", + "link": "https://meet.google.com/web3-session", + "interest_groups": ["blockchain", "web-development"], + "poster_thumbnail": "https://cdn.example.com/posters/web3.jpg" +} +``` + +**Minimal request (required fields only):** +```json +{ + "title": "Quick Dev Talk", + "date": "20/09/2025" +} +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Office Hours session created successfully."] }, + "response": { + "id": "c3d4e5f6-a7b8-9012-cdef-123456789012", + "title": "Introduction to Web3", + "performer": "Priya Nair", + "designation": "Blockchain Developer", + "description": "Explore decentralised applications and smart contracts.", + "date": "2025-09-15", + "link": "https://meet.google.com/web3-session", + "interest_groups": ["blockchain", "web-development"], + "poster_thumbnail": "https://cdn.example.com/posters/web3.jpg", + "status": "upcoming", + "created_at": "2025-06-27T06:45:00.000000Z", + "updated_at": "2025-06-27T06:45:00.000000Z" + } +} +``` + +**400 — Missing required fields:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Invalid data."], + "title": ["This field is required."], + "date": ["This field is required."] + }, + "response": {} +} +``` + +**400 — Wrong date format:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Invalid data."], + "date": ["Invalid date format. Expected DD/MM/YYYY (e.g. 27/06/2025)."] + }, + "response": {} +} +``` + +**400 — Non-admin:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["You do not have the required role to access this page."] + }, + "response": {} +} +``` + +--- + +### `GET /api/v1/dashboard/media-content/office-hours/{record_id}/` + +Retrieve a single session. **Public.** + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Office Hours session retrieved."] }, + "response": { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Intro to REST APIs with Django", + "performer": "Alice Thomas", + "designation": "Senior Developer", + "description": "A hands-on session covering DRF best practices.", + "date": "2025-08-15", + "link": "https://meet.google.com/xyz-abc", + "interest_groups": ["web-development", "ai"], + "poster_thumbnail": "https://cdn.example.com/poster1.jpg", + "status": "upcoming", + "created_at": "2025-06-27T06:30:00.000000Z", + "updated_at": "2025-06-27T06:30:00.000000Z" + } +} +``` + +**400 — Not found or soft-deleted:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { "general": ["Office Hours session not found."] }, + "response": {} +} +``` + +--- + +### `PATCH /api/v1/dashboard/media-content/office-hours/{record_id}/` + +Partially update a session. **Admin only.** All fields optional. + +**Request (update link + interest groups):** +```json +{ + "link": "https://meet.google.com/new-link", + "interest_groups": ["ai", "generative-ai"] +} +``` + +**Request (update date):** +```json +{ + "date": "30/09/2025" +} +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Office Hours session updated."] }, + "response": { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "title": "Intro to REST APIs with Django", + "performer": "Alice Thomas", + "designation": "Senior Developer", + "description": "A hands-on session covering DRF best practices.", + "date": "2025-09-30", + "link": "https://meet.google.com/new-link", + "interest_groups": ["ai", "generative-ai"], + "poster_thumbnail": "https://cdn.example.com/poster1.jpg", + "status": "upcoming", + "created_at": "2025-06-27T06:30:00.000000Z", + "updated_at": "2025-06-27T08:15:00.000000Z" + } +} +``` + +--- + +### `DELETE /api/v1/dashboard/media-content/office-hours/{record_id}/` + +Soft-delete a session. **Admin only.** + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Office Hours session deleted."] }, + "response": {} +} +``` + +**400 — Record not found:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { "general": ["Office Hours session not found."] }, + "response": {} +} +``` + +--- + +## 🥭 Salt Mango Tree + +**Content type discriminator:** `salt_mango_tree` +**Date format:** `YYYY-MM-DD` + +> **Note:** The CMS field `topic` is accepted in write requests and returned in responses. It maps internally to the `title` column in the database. + +### Field Reference + +| Field | Type | Required | Max Length | Notes | +|-------|------|----------|------------|-------| +| `topic` | string | ✅ | 300 | Episode topic | +| `campus` | string | ✅ | 200 | Campus name | +| `date` | string | ✅ | — | Must be `YYYY-MM-DD` | +| `zone` | enum | ❌ | — | `north` / `central` / `south` | +| `description` | string | ❌ | unlimited | Episode description | +| `link` | URL | ❌ | 500 | Streaming link | + +--- + +### `GET /api/v1/dashboard/media-content/salt-mango-tree/` + +List active SMT episodes. **Public.** + +**Search fields:** `topic`, `campus`, `description` + +**Example:** +``` +GET /api/v1/dashboard/media-content/salt-mango-tree/?zone=north&status=upcoming +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "d4e5f6a7-b8c9-0123-defa-234567890123", + "topic": "Building Sustainable Startups", + "campus": "NIT Calicut", + "zone": "north", + "date": "2025-08-20", + "description": "Student founders share their journey from idea to product.", + "link": "https://youtube.com/live/smt-ep12", + "status": "upcoming", + "created_at": "2025-06-27T09:00:00.000000Z", + "updated_at": "2025-06-27T09:00:00.000000Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +### `POST /api/v1/dashboard/media-content/salt-mango-tree/` + +Create an SMT episode. **Admin only.** + +**Full request body:** +```json +{ + "topic": "AI in Agriculture", + "campus": "Kerala Agricultural University", + "zone": "south", + "date": "2025-10-05", + "description": "How students are using AI to solve farming challenges.", + "link": "https://youtube.com/live/smt-ep15" +} +``` + +**Minimal request:** +```json +{ + "topic": "Open Source Culture", + "campus": "IIT Palakkad", + "date": "2025-10-15" +} +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Salt Mango Tree episode created successfully."] }, + "response": { + "id": "e5f6a7b8-c9d0-1234-efab-345678901234", + "topic": "AI in Agriculture", + "campus": "Kerala Agricultural University", + "zone": "south", + "date": "2025-10-05", + "description": "How students are using AI to solve farming challenges.", + "link": "https://youtube.com/live/smt-ep15", + "status": "upcoming", + "created_at": "2025-06-27T09:15:00.000000Z", + "updated_at": "2025-06-27T09:15:00.000000Z" + } +} +``` + +**400 — Missing required fields:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Invalid data."], + "campus": ["This field is required."] + }, + "response": {} +} +``` + +**400 — Invalid zone value:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Invalid data."], + "zone": ["\"east\" is not a valid choice."] + }, + "response": {} +} +``` + +**400 — Wrong date format (e.g. DD/MM/YYYY sent instead of YYYY-MM-DD):** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Invalid data."], + "date": ["Date has wrong format. Use one of these formats instead: YYYY-MM-DD."] + }, + "response": {} +} +``` + +--- + +### `GET /api/v1/dashboard/media-content/salt-mango-tree/{record_id}/` + +Retrieve a single SMT episode. **Public.** + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Salt Mango Tree episode retrieved."] }, + "response": { + "id": "d4e5f6a7-b8c9-0123-defa-234567890123", + "topic": "Building Sustainable Startups", + "campus": "NIT Calicut", + "zone": "north", + "date": "2025-08-20", + "description": "Student founders share their journey from idea to product.", + "link": "https://youtube.com/live/smt-ep12", + "status": "upcoming", + "created_at": "2025-06-27T09:00:00.000000Z", + "updated_at": "2025-06-27T09:00:00.000000Z" + } +} +``` + +**400 — Not found or soft-deleted:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { "general": ["Salt Mango Tree episode not found."] }, + "response": {} +} +``` + +--- + +### `PATCH /api/v1/dashboard/media-content/salt-mango-tree/{record_id}/` + +Partially update an SMT episode. **Admin only.** + +**Request (update zone and link):** +```json +{ + "zone": "central", + "link": "https://youtube.com/live/smt-ep12-v2" +} +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Salt Mango Tree episode updated."] }, + "response": { + "id": "d4e5f6a7-b8c9-0123-defa-234567890123", + "topic": "Building Sustainable Startups", + "campus": "NIT Calicut", + "zone": "central", + "date": "2025-08-20", + "description": "Student founders share their journey from idea to product.", + "link": "https://youtube.com/live/smt-ep12-v2", + "status": "upcoming", + "created_at": "2025-06-27T09:00:00.000000Z", + "updated_at": "2025-06-27T10:00:00.000000Z" + } +} +``` + +--- + +### `DELETE /api/v1/dashboard/media-content/salt-mango-tree/{record_id}/` + +Soft-delete an SMT episode. **Admin only.** + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Salt Mango Tree episode deleted."] }, + "response": {} +} +``` + +--- + +## 📻 Inspiration Station Radio + +**Content type discriminator:** `inspiration_station` +**Date format:** `YYYY-MM-DD` + +> **Note:** Identical field structure to Salt Mango Tree. `topic` is used in request and response, mapped to `title` internally. + +### Field Reference + +| Field | Type | Required | Max Length | Notes | +|-------|------|----------|------------|-------| +| `topic` | string | ✅ | 300 | Episode topic | +| `campus` | string | ✅ | 200 | Campus name | +| `date` | string | ✅ | — | Must be `YYYY-MM-DD` | +| `zone` | enum | ❌ | — | `north` / `central` / `south` | +| `description` | string | ❌ | unlimited | Episode description | +| `link` | URL | ❌ | 500 | Streaming link | + +--- + +### `GET /api/v1/dashboard/media-content/inspiration-station/` + +List active Inspiration Station episodes. **Public.** + +**Search fields:** `topic`, `campus`, `description` + +**Example:** +``` +GET /api/v1/dashboard/media-content/inspiration-station/?status=completed&perPage=3 +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": [] }, + "response": { + "data": [ + { + "id": "f6a7b8c9-d0e1-2345-fabc-456789012345", + "topic": "From Classroom to Startup", + "campus": "IIM Kozhikode", + "zone": "south", + "date": "2025-04-10", + "description": "A founder shares how an MBA project became a funded startup.", + "link": null, + "status": "completed", + "created_at": "2025-04-01T05:00:00.000000Z", + "updated_at": "2025-04-01T05:00:00.000000Z" + } + ], + "pagination": { + "count": 1, + "totalPages": 1, + "isNext": false, + "isPrev": false, + "nextPage": null + } + } +} +``` + +--- + +### `POST /api/v1/dashboard/media-content/inspiration-station/` + +Create an Inspiration Station episode. **Admin only.** + +**Full request body:** +```json +{ + "topic": "Women in Tech: Breaking Barriers", + "campus": "Model Engineering College", + "zone": "central", + "date": "2025-11-01", + "description": "Female founders and engineers share their stories and advice.", + "link": "https://youtube.com/live/is-ep20" +} +``` + +**Minimal request:** +```json +{ + "topic": "Building in Public", + "campus": "CUSAT", + "date": "2025-11-10" +} +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Inspiration Station episode created successfully."] }, + "response": { + "id": "b8c9d0e1-f2a3-4567-bcde-678901234567", + "topic": "Women in Tech: Breaking Barriers", + "campus": "Model Engineering College", + "zone": "central", + "date": "2025-11-01", + "description": "Female founders and engineers share their stories and advice.", + "link": "https://youtube.com/live/is-ep20", + "status": "upcoming", + "created_at": "2025-06-27T10:00:00.000000Z", + "updated_at": "2025-06-27T10:00:00.000000Z" + } +} +``` + +**400 — Invalid zone:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { + "general": ["Invalid data."], + "zone": ["\"east\" is not a valid choice."] + }, + "response": {} +} +``` + +--- + +### `GET /api/v1/dashboard/media-content/inspiration-station/{record_id}/` + +Retrieve a single Inspiration Station episode. **Public.** + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Inspiration Station episode retrieved."] }, + "response": { + "id": "b8c9d0e1-f2a3-4567-bcde-678901234567", + "topic": "Women in Tech: Breaking Barriers", + "campus": "Model Engineering College", + "zone": "central", + "date": "2025-11-01", + "description": "Female founders and engineers share their stories and advice.", + "link": "https://youtube.com/live/is-ep20", + "status": "upcoming", + "created_at": "2025-06-27T10:00:00.000000Z", + "updated_at": "2025-06-27T10:00:00.000000Z" + } +} +``` + +**400 — Not found or soft-deleted:** +```json +{ + "hasError": true, + "statusCode": 400, + "message": { "general": ["Inspiration Station episode not found."] }, + "response": {} +} +``` + +--- + +### `PATCH /api/v1/dashboard/media-content/inspiration-station/{record_id}/` + +Partially update an episode. **Admin only.** + +**Request:** +```json +{ + "topic": "Women in Tech: Breaking Barriers (Extended Edition)", + "date": "2025-11-05" +} +``` + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Inspiration Station episode updated."] }, + "response": { + "id": "b8c9d0e1-f2a3-4567-bcde-678901234567", + "topic": "Women in Tech: Breaking Barriers (Extended Edition)", + "campus": "Model Engineering College", + "zone": "central", + "date": "2025-11-05", + "description": "Female founders and engineers share their stories and advice.", + "link": "https://youtube.com/live/is-ep20", + "status": "upcoming", + "created_at": "2025-06-27T10:00:00.000000Z", + "updated_at": "2025-06-27T11:00:00.000000Z" + } +} +``` + +--- + +### `DELETE /api/v1/dashboard/media-content/inspiration-station/{record_id}/` + +Soft-delete an episode. **Admin only.** + +**200 OK:** +```json +{ + "hasError": false, + "statusCode": 200, + "message": { "general": ["Inspiration Station episode deleted."] }, + "response": {} +} +``` + +--- + +## Error Reference + +| Scenario | HTTP Status | `hasError` | Sample Message | +|----------|------------|------------|----------------| +| Success | `200` | `false` | Varies | +| Validation failure | `400` | `true` | Field-level errors inside `message` | +| Record not found | `400` | `true` | `" not found."` | +| Soft-deleted record accessed | `400` | `true` | `" not found."` | +| Wrong type ID on wrong endpoint | `400` | `true` | `" not found."` | +| Non-admin write attempt | `400` | `true` | `"You do not have the required role..."` | +| No / invalid JWT | `401` / `403` | `true` | `"Invalid token header"` | + +--- + +## Enum Reference + +### Zone (Salt Mango Tree & Inspiration Station) + +| API Value | Label | +|-----------|-------| +| `north` | North | +| `central` | Central | +| `south` | South | + +### Content Type Discriminator (read-only, set per endpoint) + +| Value | Module | +|-------|--------| +| `office_hours` | Office Hours | +| `salt_mango_tree` | Salt Mango Tree | +| `inspiration_station` | Inspiration Station Radio | + +--- + +## Implementation Notes + +- All `id` fields are **UUID v4** strings. +- `created_at` / `updated_at` are ISO 8601 UTC timestamps. +- `status` is **computed at read time** (`date > today` is upcoming, `date == today` is ongoing, `date < today` is completed) — not stored in the DB. +- `content_type` is **automatically set** by the endpoint — it cannot be overridden via the request body. +- Soft-deleted records (`deleted_at IS NOT NULL`) are **invisible** to all list and detail endpoints. +- An ID from one content type is **not accessible** through another type's endpoint (e.g. an SMT id on the Office Hours detail URL returns 400). diff --git a/api_reference.md b/api_reference.md new file mode 100644 index 000000000..76302c97e --- /dev/null +++ b/api_reference.md @@ -0,0 +1,390 @@ +# API Reference + +- `api/v1/` + - `auth/` + - `google-mobile/` + - `apple-mobile/` + - `refresh-token/` + - `register/` + - `/` (root) + - `role/list/` + - `colleges/` + - `department/list/` + - `location/` + - `country/list/` + - `state/list/` + - `district/list/` + - `college/list/` + - `company/list/` + - `community/list/` + - `schools/list/` + - `area-of-interest/list/` + - `lc/user-validation/` + - `email-verification/` + - `user-country/` + - `user-state/` + - `user-zone/` + - `select-domains/` + - `select-endgoals/` + - `connect-discord/` + - `organization/create/` + - `leaderboard/` + - `students/` + - `students-monthly/` + - `college/` + - `college-monthly/` + - `wadhwani-college/` + - `wadhwani-zonal/` + - `dashboard/` + - `user/` + - `preferences/` + - `search/` + - `verification/` + - `verification/csv/` + - `verification//` + - `organization/` + - `organization/list/` + - `info/` + - `forgot-password/` + - `reset-password/verify-token//` + - `reset-password//` + - `profile/update/` + - `csv/` + - `/` (list-user) + - `/` + - `zonal/` + - `zonal-details/` + - `top-districts/` + - `student-level/` + - `student-details/` + - `student-details/csv/` + - `college-details/` + - `college-details/csv/` + - `district/` + - `district-details/` + - `top-campus/` + - `student-level/` + - `student-details/` + - `student-details/csv/` + - `college-details/` + - `college-details/csv/` + - `campus/` + - `campus-details/` + - `student-level/` + - `student-level//` + - `student-details/` + - `student-details/csv/` + - `weekly-karma/` + - `weekly-karma//` + - `change-student-type//` + - `transfer-lead-role/` + - `transfer-enabler-role/` + - `transfer-ig-role/` + - `/` + - `roles/` + - `user-role//` + - `base-template/` + - `bulk-assign/` + - `bulk-assign//` + - `bulk-assign-excel/` + - `user-role/` + - `/` + - `csv/` + - `/` + - `ig/` + - `/` + - `list/` + - `csv/` + - `/` + - `get//` + - `task/` + - `list-task-type/` + - `task-type//` + - `channel/` + - `ig/` + - `organization/` + - `level/` + - `task-types/` + - `/` + - `list/` + - `csv/` + - `import/` + - `base-template/` + - `events/` + - `/` + - `profile/` + - `/` + - `badges/` + - `user-profile/` + - `ig-edit/` + - `user-profile//` + - `user-log/` + - `user-log//` + - `share-user-profile/` + - `share-user-profile//` + - `rank//` + - `get-user-levels/` + - `get-user-levels//` + - `socials/edit/` + - `socials/` + - `socials//` + - `qrcode-get//` + - `change-password/` + - `userterm-approved//` + - `karma-feed/` + - `user-level-feed/` + - `user-preferences/` + - `permute//` + - `learningcircle/` + - `create/` + - `list/` + - `info//` + - `members//` + - `edit//` + - `delete//` + - `meeting/create//` + - `meeting/list-public/` + - `meeting/list/` + - `meeting/list//` + - `meeting/edit//` + - `meeting/info//` + - `meeting/delete//` + - `meeting/join//` + - `meeting/rsvp//` + - `meeting/leave//` + - `meeting/attendee-report//` + - `meeting/report//` + - `referral/` + - `/` + - `send-referral/` + - `college/` + - `/` + - `change-college/` + - `/` + - `karma-voucher/` + - `/` + - `import/` + - `export/` + - `create/` + - `update//` + - `delete//` + - `base-template/` + - `location/` + - `countries/` + - `countries/list/` + - `countries//` + - `states/` + - `states/list/` + - `states//` + - `zones/` + - `zones/list/` + - `zones//` + - `districts/` + - `districts//` + - `organisation/` + - `institutes/create/` + - `institutes/edit//` + - `institutes/delete//` + - `institutes//csv/` + - `institutes/info//` + - `institutes/prefill//` + - `institutes//` + - `institutes///` + - `institutes/org/affiliation/show/` + - `institutes/org/affiliation/create/` + - `institutes/org/affiliation/edit//` + - `institutes/org/affiliation/delete//` + - `departments/` + - `departments/create/` + - `departments/edit//` + - `departments/delete//` + - `affiliation/list/` + - `merge_organizations//` + - `karma-type/create/` + - `karma-log/create/` + - `base-template/` + - `import/` + - `transfer/` + - `verify/list/` + - `verify//` + - `dynamic-management/` + - `dynamic-role/` + - `dynamic-role/create/` + - `dynamic-role/delete//` + - `dynamic-role/update//` + - `dynamic-user/` + - `dynamic-user/create/` + - `dynamic-user/delete//` + - `dynamic-user/update//` + - `types/` + - `roles/` + - `error-log/` + - `/` + - `graph/` + - `tab/` + - `patch//` + - `/` + - `view//` + - `clear//` + - `affiliation/` + - `/` + - `/` + - `channels/` + - `/` + - `/` + - `discord-moderator/` + - `tasklist/` + - `pendingcounts/` + - `leaderboard/` + - `events/` + - `/` + - `/` + - `coupon/` + - `verify-coupon/` + - `projects/` + - `/` + - `/` + - `vote/` + - `vote//` + - `comment/` + - `comment//` + - `achievement/` + - `list/` + - `create/` + - `update//` + - `delete//` + - `list/user//` + - `issue-vc/` + - `task-report/` + - `/` + - `/` + - `group-by-reporter/` + - `integrations/` + - `kkem/` + - `login/` + - `authorization/` + - `authorization//` + - `user/status//` + - `user//` + - `users/` + - `users//` + - `hackathon-stats/` + - `wadhwani/` + - `auth-token/` + - `user-login/` + - `course-details/` + - `course-enroll-status/` + - `course-quiz-data/` + - `qseverse/` + - `issue-vc/` + - `connected-users/` + - `connected-users/search` + - `qs-credentials/` + - `url-shortener/` + - `create/` + - `edit//` + - `list/` + - `delete//` + - `get-analytics//` + - `protected/` + - `organisation/` + - `institutes///` + - `get-institutes//` + - `hackathon/` + - `list-hackathons/` + - `list-hackathons/upcoming/` + - `list-hackathons//` + - `info//` + - `create-hackathon/` + - `edit-hackathon//` + - `delete-hackathon//` + - `publish-hackathon//` + - `submit-hackathon/` + - `list-organiser-hackathons//` + - `add-organiser//` + - `delete-organiser//` + - `list-applicants/` + - `list-applicants//` + - `list-form//` + - `list-organisations/` + - `list-districts/` + - `list-default-form-fields/` + - `notification/` + - `list/` + - `delete/id//` + - `delete/all/` + - `public/` + - `lc-list` + - `/lc-details/` + - `lc-dashboard/` + - `lc-report/` + - `college-wise-lc-report/` + - `college-wise-lc-report/csv/` + - `lc-report/csv/` + - `lc-enrollment/` + - `lc-enrollment/csv/` + - `global-count/` + - `gta-sandshore/` + - `profile-pic//` + - `list-ig/` + - `list-ig-top100/` + - `list/levels/` + - `leaderboard/top-100/` + - `list/college/` + - `list/district/` + - `list/state/` + - `list/country/` + - `top100/` + - `leaderboard/` + - `launchpad/` + - `register-company/` + - `register-recruiter/` + - `company-list/` + - `company-list-verified/` + - `login-company/` + - `login-recruiter/` + - `refresh-token/` + - `add-job/` + - `job//` + - `company-info/` + - `recruiter-info/` + - `company-verify/` + - `list-jobs/` + - `verify-task/` + - `list-launchpad-students//` + - `hire-requests/` + - `send-job-invitations/` + - `student/job-invitations/` + - `student/apply-to-job/` + - `accepted-students/` + - `accepted-students//` + - `schedule-interview/` + - `application-final-decision/` + - `delete-company/` + - `leaderboard/` + - `task-completed-leaderboard/` + - `list-participants/` + - `launchpad-details/` + - `college-data/` + - `user-college-link/` + - `user-college-link/` + - `user-college-link-public/` + - `user-profile/` + - `user-college-data/` + - `bulk-user-college-link/` + - `list-participants-admin/` + - `user-details//` + - `socials//` + - `user-log//` + - `get-user-levels//` + - `ig-leaderboard/` + - `forgot-password/` + - `reset-password/` + - `verify-reset-token/` + - `change-password/` + - `donate/` + - `order/` + - `verify/` + - `subscription/create/` + - `subscription/verify/` + - `bank-transfer/` diff --git a/check_task_types.py b/check_task_types.py new file mode 100644 index 000000000..eed1a5368 --- /dev/null +++ b/check_task_types.py @@ -0,0 +1,11 @@ +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mulearnbackend.settings') +django.setup() + +from db.task import TaskType + +print("Task Types:") +for tt in TaskType.objects.all(): + print(tt.title) diff --git a/db/achievement.py b/db/achievement.py index 48d72aa4e..93f69e9a9 100644 --- a/db/achievement.py +++ b/db/achievement.py @@ -1,15 +1,15 @@ from django.db import models import uuid -from db.task import Level +from db.task import Level, InterestGroup from db.user import User class Achievement(models.Model): id = models.CharField(primary_key=True, default=uuid.uuid4, max_length=36) name = models.CharField(max_length=75, unique=True) - level_id = models.ForeignKey(Level, on_delete=models.CASCADE, db_column="level_id") + level_id = models.ForeignKey(Level, on_delete=models.CASCADE, db_column="level_id", null=True, blank=True) description = models.CharField(max_length=300) - icon = models.CharField(max_length=100) + icon = models.CharField(max_length=100, blank=True, default="") has_vc = models.BooleanField() tags = models.JSONField() type = models.CharField(max_length=36) @@ -50,8 +50,9 @@ class UserAchievementsLog(models.Model): db_column="achievement_id", db_index=True, ) + rule_version = models.IntegerField(default=1) is_issued = models.BooleanField(default=False) - vc_url = models.CharField(max_length=100) + vc_url = models.CharField(max_length=100, blank=True, default="") updated_by = models.ForeignKey( User, on_delete=models.CASCADE, @@ -70,3 +71,202 @@ class UserAchievementsLog(models.Model): class Meta: db_table = "user_achievements_log" managed = False + unique_together = [["user_id", "achievement_id"]] + + +# ============================================================================ +# NEW MODELS FOR ACHIEVEMENT SYSTEM +# ============================================================================ + + +class AchievementEvent(models.Model): + """Canonical events for achievement evaluation - immutable, append-only""" + id = models.CharField(primary_key=True, default=uuid.uuid4, max_length=36) + event_id = models.CharField(max_length=36, unique=True) # Idempotency key + event_type = models.CharField(max_length=50) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="achievement_events", + db_column="user_id", + ) + region = models.CharField(max_length=50, null=True, blank=True) + metadata = models.JSONField() + processed = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "achievement_event" + managed = False + + +class AchievementRule(models.Model): + """Versioned rules for achievement eligibility - immutable once created""" + RULE_TYPE_CHOICES = [ + ("ig_karma", "IG Karma Threshold"), + ("skill", "Skill Task Count"), + ("streak", "Daily Streak"), + ("milestone", "Total Milestone"), + ("event", "Event Attendance"), + ] + + id = models.CharField(primary_key=True, default=uuid.uuid4, max_length=36) + achievement = models.ForeignKey( + Achievement, + on_delete=models.CASCADE, + related_name="rules", + db_column="achievement_id", + ) + version = models.IntegerField(default=1) + rule_type = models.CharField(max_length=30, choices=RULE_TYPE_CHOICES) + conditions = models.JSONField() + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="achievement_rules_created", + db_column="created_by", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "achievement_rule" + managed = False + unique_together = [["achievement", "version"]] + + +class UserSkillProgress(models.Model): + """Pre-aggregated skill progress for O(1) eligibility checks""" + id = models.CharField(primary_key=True, default=uuid.uuid4, max_length=36) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="skill_progress", + db_column="user_id", + ) + skill_id = models.CharField(max_length=36) # IG id or custom skill id + completed_task_count = models.IntegerField(default=0) + total_karma = models.IntegerField(default=0) + last_task_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "user_skill_progress" + managed = False + unique_together = [["user", "skill_id"]] + + +class UserIgKarma(models.Model): + """Pre-aggregated karma per user+IG for fast lookups""" + id = models.CharField(primary_key=True, default=uuid.uuid4, max_length=36) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="ig_karma", + db_column="user_id", + ) + ig = models.ForeignKey( + InterestGroup, + on_delete=models.CASCADE, + related_name="user_karma", + db_column="ig_id", + ) + total_karma = models.IntegerField(default=0) + task_count = models.IntegerField(default=0) + last_activity = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "user_ig_karma" + managed = False + unique_together = [["user", "ig"]] + + +class UserDailyActivity(models.Model): + """Daily activity tracking - source of truth for streaks""" + id = models.CharField(primary_key=True, default=uuid.uuid4, max_length=36) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="daily_activities", + db_column="user_id", + ) + activity_date = models.DateField() + has_task = models.BooleanField(default=False) + has_karma = models.BooleanField(default=False) + has_login = models.BooleanField(default=False) + task_count = models.IntegerField(default=0) + karma_earned = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "user_daily_activity" + managed = False + unique_together = [["user", "activity_date"]] + + +class UserStreak(models.Model): + """Streak tracking derived from daily activity""" + id = models.CharField(primary_key=True, default=uuid.uuid4, max_length=36) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="streaks", + db_column="user_id", + ) + streak_type = models.CharField(max_length=30) # daily_login, daily_task + current_streak = models.IntegerField(default=0) + longest_streak = models.IntegerField(default=0) + last_active = models.DateField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "user_streak" + managed = False + unique_together = [["user", "streak_type"]] + + +class AchievementAuditLog(models.Model): + """Full audit trail for achievements - for debugging and compliance""" + ACTION_CHOICES = [ + ("issued", "Achievement Issued"), + ("revoked", "Achievement Revoked"), + ("vc_issued", "VC Issued"), + ("vc_failed", "VC Issuance Failed"), + ] + + id = models.CharField(primary_key=True, default=uuid.uuid4, max_length=36) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="achievement_audit_logs", + db_column="user_id", + ) + achievement = models.ForeignKey( + Achievement, + on_delete=models.CASCADE, + related_name="audit_logs", + db_column="achievement_id", + ) + action = models.CharField(max_length=20, choices=ACTION_CHOICES) + rule_version = models.IntegerField(null=True, blank=True) + metadata = models.JSONField(null=True, blank=True) + performed_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="audit_logs_performed", + db_column="performed_by", + null=True, + blank=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "achievement_audit_log" + managed = False + diff --git a/db/apps.py b/db/apps.py index 062442734..0e5e6bd22 100644 --- a/db/apps.py +++ b/db/apps.py @@ -11,7 +11,8 @@ class DbConfig(AppConfig): name = "db" def ready(self) -> None: - # from db import organization + # Models are registered via db/models.py, which Django imports + # automatically during app population (before ready() runs). _ready = super().ready() self.check_system_user_exists() return _ready diff --git a/db/campus.py b/db/campus.py new file mode 100644 index 000000000..ca91242c8 --- /dev/null +++ b/db/campus.py @@ -0,0 +1,49 @@ +import uuid + +from django.db import models + +from django.conf import settings +from .user import User +from .organization import Organization +from .task import InterestGroup + +# fmt: off +# noinspection PyPep8 + + +class CampusIGChapter(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='campus_ig_chapter_org') + ig = models.ForeignKey(InterestGroup, on_delete=models.CASCADE, related_name='campus_ig_chapter_ig') + lead = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='lead_id', + related_name='campus_ig_chapter_lead', blank=True, null=True) + description = models.TextField(blank=True, null=True) + icon_link = models.URLField(max_length=500, blank=True, null=True) + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='created_by', + related_name='campus_ig_chapter_created_by') + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='updated_by', + related_name='campus_ig_chapter_updated_by') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'campus_ig_chapter' + + +class CampusSocialLink(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='campus_social_link_org') + platform = models.CharField(max_length=30) + url = models.URLField(max_length=500) + created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='created_by', + related_name='campus_social_link_created_by') + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='updated_by', + related_name='campus_social_link_updated_by') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'campus_social_link' diff --git a/db/comic.py b/db/comic.py new file mode 100644 index 000000000..335a03b0c --- /dev/null +++ b/db/comic.py @@ -0,0 +1,302 @@ +import uuid + +from django.db import models +from django.conf import settings + +from .user import User + +class Comic(models.Model): + + class Status(models.TextChoices): + DRAFT = 'draft', 'Draft' + PUBLISHED = 'published', 'Published' + ARCHIVED = 'archived', 'Archived' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + title = models.CharField(max_length=150) + slug = models.CharField(max_length=75, unique=True) + description = models.TextField(blank=True, null=True) + cover_image_key = models.CharField(max_length=255, blank=True, null=True) # S3 object key + + status = models.CharField( + max_length=10, choices=Status.choices, default=Status.DRAFT + ) + + like_count = models.PositiveIntegerField(default=0) + comment_count = models.PositiveIntegerField(default=0) + bookmark_count = models.PositiveIntegerField(default=0) + + published_at = models.DateTimeField(blank=True, null=True) + + # Soft delete + deleted_at = models.DateTimeField(blank=True, null=True) + deleted_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + db_column='deleted_by', related_name='comic_deleted_by' + ) + + # Audit + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='comic_updated_by' + ) + updated_at = models.DateTimeField() + + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='comic_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'comic' + indexes = [ + models.Index(fields=['status', 'created_at'], name='idx_comic_status_created'), + models.Index(fields=['created_by'], name='idx_comic_created_by'), + ] + + +class Genre(models.Model): + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + name = models.CharField(max_length=75, unique=True) + slug = models.CharField(max_length=75, unique=True) + is_active= models.BooleanField(default=True) + # Audit + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='genre_updated_by' + ) + updated_at = models.DateTimeField() + + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='genre_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'genre' + + +class ComicGenreLink(models.Model): + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + comic = models.ForeignKey( + Comic, on_delete=models.CASCADE, + db_column='comic_id', related_name='genre_links' + ) + genre = models.ForeignKey( + Genre, on_delete=models.CASCADE, + db_column='genre_id', related_name='comic_links' + ) + + # Audit + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='comic_genre_link_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'comic_genre_link' + unique_together = [('comic', 'genre')] + indexes = [ + models.Index(fields=['genre'], name='idx_comic_genre_genre'), + ] + + +class ComicContributorLink(models.Model): + + class ContributorType(models.TextChoices): + CREATOR = 'creator', 'Creator' + WRITER = 'writer', 'Writer' + ARTIST = 'artist', 'Artist' + COLORIST = 'colorist', 'Colorist' + EDITOR = 'editor', 'Editor' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + comic = models.ForeignKey( + Comic, on_delete=models.CASCADE, + db_column='comic_id', related_name='contributor_links' + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='user_id', related_name='comic_contributor_links' + ) + contributor_type = models.CharField( + max_length=10, choices=ContributorType.choices + ) + + # Audit + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='comic_contributor_link_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'comic_contributor_link' + unique_together = [('comic', 'user', 'contributor_type')] + indexes = [ + models.Index(fields=['user'], name='idx_contributor_user'), + models.Index(fields=['comic', 'contributor_type'], name='idx_contributor_comic_type'), + ] + + +class Chapter(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + comic = models.ForeignKey( + Comic, on_delete=models.CASCADE, + db_column='comic_id', related_name='chapters' + ) + title = models.CharField(max_length=150) + slug = models.CharField(max_length=75, unique=True) + description = models.TextField(blank=True, null=True) + chapter_number = models.DecimalField(max_digits=6, decimal_places=2) + cover_image_key = models.CharField(max_length=255, blank=True, null=True) + + status = models.CharField( + max_length=10, choices=Comic.Status.choices, default=Comic.Status.DRAFT + ) + + published_at = models.DateTimeField(blank=True, null=True) + + # Soft delete + deleted_at = models.DateTimeField(blank=True, null=True) + deleted_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + db_column='deleted_by', related_name='chapter_deleted_by' + ) + + # Audit + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='chapter_updated_by' + ) + updated_at = models.DateTimeField() + + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='chapter_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'chapter' + unique_together = [('comic', 'chapter_number')] + indexes = [ + models.Index(fields=['status', 'created_at'], name='idx_chapter_status_created'), + models.Index(fields=['comic', 'status'], name='idx_chapter_comic_status'), + ] + + +class ChapterPage(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + chapter = models.ForeignKey( + Chapter, on_delete=models.CASCADE, + db_column='chapter_id', related_name='pages' + ) + page_number = models.PositiveIntegerField() + image_key = models.CharField(max_length=255) + + # Soft delete + deleted_at = models.DateTimeField(blank=True, null=True) + deleted_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + db_column='deleted_by', related_name='chapter_page_deleted_by' + ) + + # Audit + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='chapter_page_updated_by' + ) + updated_at = models.DateTimeField() + + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='chapter_page_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'chapter_page' + unique_together = [('chapter', 'page_number')] + indexes = [ + models.Index(fields=['chapter', 'page_number'], name='idx_chapter_page_order'), + ] + + +class ComicLikeLink(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + comic = models.ForeignKey( + Comic, on_delete=models.CASCADE, + db_column='comic_id', related_name='like_links' + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='user_id', related_name='comic_like_links' + ) + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='comic_like_link_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'comic_like_link' + unique_together = [('comic', 'user')] + + +class ComicBookmarkLink(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + comic = models.ForeignKey( + Comic, on_delete=models.CASCADE, + db_column='comic_id', related_name='bookmark_links' + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='user_id', related_name='comic_bookmark_links' + ) + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='comic_bookmark_link_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'comic_bookmark_link' + unique_together = [('comic', 'user')] + + +class ComicReadingProgress(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + user = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='user_id', related_name='comic_reading_progress' + ) + comic = models.ForeignKey( + Comic, on_delete=models.CASCADE, + db_column='comic_id', related_name='reading_progress' + ) + last_chapter = models.ForeignKey( + Chapter, on_delete=models.SET_NULL, null=True, blank=True, + db_column='last_chapter_id', related_name='reading_progress_refs' + ) + last_page_number = models.IntegerField(blank=True, null=True) + updated_at = models.DateTimeField() + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'comic_reading_progress' + unique_together = [('user', 'comic')] diff --git a/db/comic_comment.py b/db/comic_comment.py new file mode 100644 index 000000000..904d7ee83 --- /dev/null +++ b/db/comic_comment.py @@ -0,0 +1,67 @@ +import uuid + +from django.db import models +from django.conf import settings + +from .user import User +from .comic import Comic + + +class ComicComment(models.Model): + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + + comic = models.ForeignKey( + Comic, on_delete=models.CASCADE, + db_column='comic_id', related_name='comments' + ) + chapter_id = models.CharField(max_length=36, null=True, blank=True) + parent = models.ForeignKey( + 'self', on_delete=models.CASCADE, null=True, blank=True, + db_column='parent_id', related_name='replies' + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='user_id', related_name='comic_comments' + ) + message = models.TextField() + + # Soft delete + deleted_at = models.DateTimeField(null=True, blank=True) + deleted_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + db_column='deleted_by', related_name='comic_comment_deleted_by' + ) + + # Audit + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='comic_comment_updated_by' + ) + updated_at = models.DateTimeField() + + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='comic_comment_created_by' + ) + created_at = models.DateTimeField() + + class Meta: + managed = False + db_table = 'comic_comment' + indexes = [ + models.Index(fields=['comic', 'created_at'], name='idx_comic_comment_comic'), + models.Index(fields=['chapter_id', 'created_at'], name='idx_comic_comment_chapter'), + models.Index(fields=['parent'], name='idx_comic_comment_parent'), + models.Index(fields=['user'], name='idx_comic_comment_user'), + ] + + @property + def is_deleted(self): + return self.deleted_at is not None + + @property + def is_edited(self): + if self.updated_at and self.created_at: + return self.updated_at > self.created_at + return False diff --git a/db/company.py b/db/company.py new file mode 100644 index 000000000..7da36a36c --- /dev/null +++ b/db/company.py @@ -0,0 +1,45 @@ +import uuid +from django.db import models + +class Company(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + company_user = models.OneToOneField('User', on_delete=models.CASCADE, related_name='company_profile') + name = models.CharField(max_length=75, unique=True) + logo = models.TextField(null=True, blank=True) + description = models.TextField() + short_pitch = models.CharField(max_length=900, null=True, blank=True) + industry_sector = models.CharField(max_length=75, null=True, blank=True) + website_link = models.TextField(null=True, blank=True) + email = models.CharField(max_length=100, null=True, blank=True) + slug = models.CharField(max_length=100, unique=True) + status = models.CharField(max_length=30, default="pending") + location = models.CharField(max_length=150, null=True, blank=True) + district = models.ForeignKey('District', on_delete=models.SET_NULL, null=True, blank=True, related_name='companies') + state = models.ForeignKey('State', on_delete=models.SET_NULL, null=True, blank=True, related_name='companies_state') + country = models.ForeignKey('Country', on_delete=models.SET_NULL, null=True, blank=True, related_name='companies_country') + legal_name = models.CharField(max_length=150, null=True, blank=True) + registration_number = models.CharField(max_length=100, null=True, blank=True) + tax_id = models.CharField(max_length=100, null=True, blank=True) + company_size = models.CharField(max_length=50, null=True, blank=True) + linkedin_url = models.TextField(null=True, blank=True) + verification_document_url = models.TextField(null=True, blank=True) + verification_requested_at = models.DateTimeField(null=True, blank=True) + verified_at = models.DateTimeField(null=True, blank=True) + verified_by = models.CharField(max_length=36, null=True, blank=True) + rejection_reason = models.TextField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(null=True, blank=True) + updated_by = models.CharField(max_length=36, null=True, blank=True) + deleted_by = models.CharField(max_length=36, null=True, blank=True) + founded_year = models.PositiveSmallIntegerField(null=True, blank=True) + remote_policy = models.CharField(max_length=20, null=True, blank=True) + culture_text = models.TextField(null=True, blank=True) + tech_stack = models.JSONField(null=True, blank=True) + perks = models.JSONField(null=True, blank=True) + testimonials = models.JSONField(null=True, blank=True) + gallery = models.JSONField(null=True, blank=True) + + class Meta: + managed = False + db_table = 'company' diff --git a/db/donation.py b/db/donation.py new file mode 100644 index 000000000..de13f1ba2 --- /dev/null +++ b/db/donation.py @@ -0,0 +1,28 @@ +from django.db import models +from db.donor import Donor + + +class Donation(models.Model): + """ + Donation model - tracks individual donations/payments. + Links to Donor for personal info, stores payment details separately. + """ + id = models.CharField(primary_key=True, max_length=36) + donor = models.ForeignKey(Donor, on_delete=models.CASCADE, db_column='donor_id', related_name='donations') + order_id = models.CharField(max_length=100, null=True, blank=True) # Razorpay order_id or subscription_id + payment_id = models.CharField(max_length=100, null=True, blank=True) + donation_name = models.CharField(max_length=100, null=True, blank=True) + payment_method = models.CharField(max_length=50, null=True, blank=True) + amount = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=10, default='INR') + donation_type = models.CharField(max_length=20) # 'one-time', 'monthly', 'yearly' + is_paid = models.BooleanField(default=False) + # Bank transfer fields + payment_status = models.CharField(max_length=30, default='COMPLETED') # 'COMPLETED', 'PENDING_VERIFICATION', 'REJECTED' + reference_code = models.CharField(max_length=50, null=True, blank=True) + proof_url = models.TextField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'donation' diff --git a/db/donor.py b/db/donor.py index be29d4946..0a7160145 100644 --- a/db/donor.py +++ b/db/donor.py @@ -4,19 +4,22 @@ class Donor(models.Model): + """ + Donor model - stores personal information about donors. + Payment tracking is handled by the separate Donation model. + """ id = models.CharField(primary_key=True, max_length=36) - payment_id = models.CharField(max_length=100) - payment_method = models.CharField(max_length=100) - amount = models.FloatField() - currency = models.CharField(max_length=30) name = models.CharField(max_length=100) email = models.EmailField(max_length=200) - company = models.CharField(max_length=100, null=True) - phone_number = models.CharField(max_length=20, null=True) - pan_number = models.CharField(max_length=10, null=True) + company = models.CharField(max_length=100, null=True, blank=True) + phone_number = models.CharField(max_length=20, null=True, blank=True) + pan_number = models.CharField(max_length=10, null=True, blank=True) + address = models.TextField(null=True, blank=True) + is_organisation = models.BooleanField(default=False) created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='created_by', related_name='donor_created_by') created_at = models.DateTimeField(auto_now_add=True) class Meta: managed = False db_table = 'donor' + diff --git a/db/events.py b/db/events.py new file mode 100644 index 000000000..124e9562d --- /dev/null +++ b/db/events.py @@ -0,0 +1,391 @@ +import uuid + +from django.db import models +from django.conf import settings + +from .user import User +from .task import Category, InterestGroup +from .organization import Organization + + +class Event(models.Model): + + class Status(models.TextChoices): + DRAFT = 'draft', 'Draft' + PENDING_CAMPUS_APPROVAL = 'pending_campus_approval', 'Pending Campus Approval' + PENDING_APPROVAL = 'pending_approval', 'Pending Approval' + PENDING_MENTOR_APPROVAL = 'pending_mentor_approval', 'Pending Mentor Approval' + PUBLISHED = 'published', 'Published' + ONGOING = 'ongoing', 'Ongoing' + COMPLETED = 'completed', 'Completed' + CANCELLED = 'cancelled', 'Cancelled' + REJECTED = 'rejected', 'Rejected' + + class VenueType(models.TextChoices): + PHYSICAL = 'physical', 'Physical' + ONLINE = 'online', 'Online' + HYBRID = 'hybrid', 'Hybrid' + + class Scope(models.TextChoices): + GLOBAL = 'global', 'Global' + CAMPUS = 'campus', 'Campus' + IG = 'ig', 'Interest Group' + CAMPUS_IG = 'campus_ig', 'Campus IG' + COMPANY = 'company', 'Company' + + class OrganiserType(models.TextChoices): + GLOBAL_IG = 'global_ig', 'Global IG' + CAMPUS_IG = 'campus_ig', 'Campus IG' + CAMPUS = 'campus', 'Campus' + COMPANY = 'company', 'Company' + ADMIN = 'admin', 'Admin' + + class EventScope(models.TextChoices): + MAKER = 'maker', 'Maker' + CODER = 'coder', 'Coder' + MANAGER = 'manager', 'Manager' + CREATIVE = 'creative', 'Creative' + + class EventType(models.TextChoices): + HACKATHON = 'hackathon', 'Hackathon' + WORKSHOP = 'workshop', 'Workshop' + WEBINAR = 'webinar', 'Webinar' + SEMINAR = 'seminar', 'Seminar' + BOOTCAMP = 'bootcamp', 'Bootcamp' + MEETUP = 'meetup', 'Meetup' + CONFERENCE = 'conference', 'Conference' + COMPETITION = 'competition', 'Competition' + IDEATHON = 'ideathon', 'Ideathon' + CULTURAL_EVENT = 'cultural_event', 'Cultural Event' + SPORTS_EVENT = 'sports_event', 'Sports Event' + COMMUNITY_EVENT = 'community_event', 'Community Event' + EXPO = 'expo', 'Expo' + NETWORKING_EVENT= 'networking_event','Networking Event' + TECH_TALK = 'tech_talk', 'Tech Talk' + OTHERS = 'others', 'Others' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + title = models.CharField(max_length=200) + slug = models.CharField(max_length=220, unique=True, db_index=True) + description = models.TextField(blank=True, null=True) + cover_image = models.CharField(max_length=512, blank=True, null=True) + banner_image = models.CharField(max_length=512, blank=True, null=True) + category = models.ForeignKey( + Category, on_delete=models.SET_NULL, null=True, blank=True, + db_column='category_id', related_name='new_events' + ) + + # Lifecycle + status = models.CharField(max_length=30, choices=Status.choices, default=Status.DRAFT) + + # Dates + start_datetime = models.DateTimeField() + end_datetime = models.DateTimeField() + registration_url = models.CharField(max_length=500, blank=True, null=True) + registration_deadline = models.DateTimeField(blank=True, null=True) + min_karma = models.BigIntegerField(blank=True, null=True) + + # Venue (in-line on the events table) + venue_type = models.CharField(max_length=20, choices=VenueType.choices, default=VenueType.ONLINE) + venue_address = models.CharField(max_length=300, blank=True, null=True) + venue_city = models.CharField(max_length=100, blank=True, null=True) + venue_maps_url = models.CharField(max_length=500, blank=True, null=True) + venue_online_link = models.CharField(max_length=500, blank=True, null=True) + venue_platform = models.CharField(max_length=100, blank=True, null=True) + + # Scope & targeting + scope = models.CharField(max_length=20, choices=Scope.choices, default=Scope.GLOBAL) + scope_org = models.ForeignKey( + Organization, on_delete=models.SET_NULL, null=True, blank=True, + db_column='scope_org_id', related_name='event_scope_org' + ) + scope_ig = models.ForeignKey( + InterestGroup, on_delete=models.SET_NULL, null=True, blank=True, + db_column='scope_ig_id', related_name='event_scope_ig' + ) + # campus_ig scope: stored as a plain ID (no dedicated campus_ig table yet) + scope_ci_id = models.CharField(max_length=36, blank=True, null=True) + + # Organiser + organiser_type = models.CharField(max_length=20, choices=OrganiserType.choices, default=OrganiserType.ADMIN) + organiser_ig = models.ForeignKey( + InterestGroup, on_delete=models.SET_NULL, null=True, blank=True, + db_column='organiser_ig_id', related_name='event_organiser_ig' + ) + organiser_org = models.ForeignKey( + Organization, on_delete=models.SET_NULL, null=True, blank=True, + db_column='organiser_org_id', related_name='event_organiser_org' + ) + organiser_ci_id = models.CharField(max_length=36, blank=True, null=True) # campus_ig reference + + # Event scope (audience type) + event_scope = models.CharField( + max_length=20, choices=EventScope.choices + ) + + # Event type + event_type = models.CharField( + max_length=20, choices=EventType.choices, default=EventType.OTHERS + ) + + # Flags & counters + is_featured = models.BooleanField(default=False) + is_collaboration = models.BooleanField(default=False) + interest_count = models.PositiveIntegerField(default=0) + tags = models.JSONField(blank=True, null=True) + user_limit = models.PositiveIntegerField(default=0) + + # Audit + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='new_event_created_by' + ) + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='new_event_updated_by' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(blank=True, null=True) + + class Meta: + managed = False + db_table = 'events' + + +class EventConnection(models.Model): + """ + Polymorphic connection table for events. + Handles: user tickets, co-owners, and all collaborator entity types. + + entity_type values: + user_ticket → user registered / expressed ticket interest + co_owner → user with full manage access + collab_ig → IG as co-host + collab_campus → campus as co-host + collab_campus_ig → campus IG chapter as co-host + collab_company → company as co-host + """ + + class EntityType(models.TextChoices): + USER_TICKET = 'user_ticket', 'User Ticket' + CO_OWNER = 'co_owner', 'Co-owner' + COLLAB_IG = 'collab_ig', 'Collaborating IG' + COLLAB_CAMPUS = 'collab_campus', 'Collaborating Campus' + COLLAB_CAMPUS_IG = 'collab_campus_ig', 'Collaborating Campus IG' + COLLAB_COMPANY = 'collab_company', 'Collaborating Company' + + COLLABORATOR_TYPES = [ + EntityType.COLLAB_IG, + EntityType.COLLAB_CAMPUS, + EntityType.COLLAB_CAMPUS_IG, + EntityType.COLLAB_COMPANY, + ] + + class InviteStatus(models.TextChoices): + PENDING = 'pending', 'Pending' + ACCEPTED = 'accepted', 'Accepted' + REJECTED = 'rejected', 'Rejected' + + class TicketStatus(models.TextChoices): + PENDING = 'pending', 'Pending' + ACTIVE = 'active', 'Active' + REMOVED = 'removed', 'Removed' + REJECTED = 'rejected', 'Rejected' + WITHDRAWN = 'withdrawn', 'Withdrawn' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + event = models.ForeignKey( + Event, on_delete=models.CASCADE, + db_column='event_id', related_name='connections' + ) + entity_type = models.CharField(max_length=25, choices=EntityType.choices) + # Polymorphic FK: user.id / interest_group.id / organization.id depending on entity_type + entity_id = models.CharField(max_length=36) + + # --- Used only when entity_type = user_ticket --- + ticket_status = models.CharField(max_length=20, blank=True, null=True) + + # --- Used only for collab_* types --- + role_label = models.CharField(max_length=100, blank=True, null=True) + invite_status = models.CharField( + max_length=20, choices=InviteStatus.choices, blank=True, null=True + ) + rejection_reason = models.CharField(max_length=500, blank=True, null=True) + responded_at = models.DateTimeField(blank=True, null=True) + + # Audit + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='event_conn_created_by' + ) + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='event_conn_updated_by' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'events_connection' + unique_together = [('event', 'entity_id', 'entity_type')] + + +class EventInterest(models.Model): + """One row per user-event "I'm Going" click.""" + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + event = models.ForeignKey( + Event, on_delete=models.CASCADE, + db_column='event_id', related_name='interests' + ) + user = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='user_id', related_name='event_interests' + ) + expressed_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'events_interest' + unique_together = [('event', 'user')] + + +class EventLog(models.Model): + """Audit trail of every save on an event.""" + + class Action: + """ + Action-type constants stored inside changed_fields['action']. + Plain class (not TextChoices) — no DB column required. + """ + CREATED = 'event_created' + UPDATED = 'event_updated' + PUBLISHED = 'event_published' + CANCELLED = 'event_cancelled' + FEATURED = 'event_featured' + UNFEATURED = 'event_unfeatured' + APPROVED = 'event_approved' + REJECTED = 'event_rejected' + CO_OWNER_ADDED = 'co_owner_added' + CO_OWNER_REMOVED = 'co_owner_removed' + COLLAB_INVITED = 'collaborator_invited' + COLLAB_ACCEPTED = 'collaborator_accepted' + COLLAB_REJECTED = 'collaborator_rejected' + COLLAB_REMOVED = 'collaborator_removed' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + event = models.ForeignKey( + Event, on_delete=models.CASCADE, + db_column='event_id', related_name='logs' + ) + edited_by = models.ForeignKey( + User, on_delete=models.RESTRICT, + db_column='edited_by', related_name='event_logs' + ) + changed_fields = models.JSONField() # e.g. ["title", "venue_city", "min_karma"] + edited_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'events_log' + +class EventTag(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + title = models.CharField(max_length=100, unique=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'event_tag' + + +class EventTagLink(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + event = models.ForeignKey( + Event, on_delete=models.CASCADE, + db_column='event_id', related_name='tag_links' + ) + tag = models.ForeignKey( + EventTag, on_delete=models.CASCADE, + db_column='tag_id', related_name='event_links' + ) + + class Meta: + managed = False + db_table = 'event_tag_link' + unique_together = [('event', 'tag')] + + +class MediaContent(models.Model): + """ + Unified model for CMS-migrated content types: + - Office Hours sessions + - Salt Mango Tree episodes + - Inspiration Station Radio episodes + + The ``content_type`` field acts as a discriminator. Type-specific + fields are nullable and only populated for the relevant type. + Soft-delete is supported via ``deleted_at``. + """ + + class ContentType(models.TextChoices): + OFFICE_HOURS = 'office_hours', 'Office Hours' + SALT_MANGO_TREE = 'salt_mango_tree', 'Salt Mango Tree' + INSPIRATION_STATION = 'inspiration_station', 'Inspiration Station Radio' + + class Zone(models.TextChoices): + NORTH = 'north', 'North' + CENTRAL = 'central', 'Central' + SOUTH = 'south', 'South' + + # ── Primary key ─────────────────────────────────────────────────────────── + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + + # ── Discriminator ───────────────────────────────────────────────────────── + content_type = models.CharField( + max_length=30, choices=ContentType.choices, db_index=True + ) + + # ── Shared fields (all types) ───────────────────────────────────────────── + # Office Hours uses "title"; SMT & Inspiration Station use "topic" — + # both are stored in this single column. + title = models.CharField(max_length=300) + date = models.DateField() + description = models.TextField(blank=True, null=True) + link = models.CharField(max_length=500, blank=True, null=True) + + # ── Office Hours specific ───────────────────────────────────────────────── + performer = models.CharField(max_length=200, blank=True, null=True) + designation = models.CharField(max_length=200, blank=True, null=True) + interest_groups = models.JSONField(blank=True, null=True) # list of IG slugs + poster_thumbnail = models.CharField(max_length=512, blank=True, null=True) + + # ── SMT & Inspiration Station specific ──────────────────────────────────── + campus = models.CharField(max_length=200, blank=True, null=True) + zone = models.CharField( + max_length=10, choices=Zone.choices, blank=True, null=True + ) + + # ── Soft delete ─────────────────────────────────────────────────────────── + deleted_at = models.DateTimeField(blank=True, null=True) + + # ── Audit ───────────────────────────────────────────────────────────────── + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='media_content_created_by' + ) + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='media_content_updated_by' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'media_content' + indexes = [ + models.Index(fields=['content_type', 'date']), + ] diff --git a/db/intern.py b/db/intern.py new file mode 100644 index 000000000..e05948af1 --- /dev/null +++ b/db/intern.py @@ -0,0 +1,155 @@ +import uuid + +from django.db import models + +from .user import User + + +class UserInternGuildLink(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='intern_guild_link') + guild = models.CharField(max_length=75) + status = models.CharField(max_length=15, default='ACTIVE') + previous_status = models.CharField(max_length=15, null=True, blank=True) + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_guild_created', db_column='created_by') + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_guild_updated', db_column='updated_by') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'user_intern_guild_link' + + +class InternTask(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + title = models.CharField(max_length=150) + description = models.TextField() + assigned_to = models.ForeignKey(User, on_delete=models.CASCADE, related_name='assigned_intern_tasks', db_column='assigned_to') + team = models.CharField(max_length=75) + category = models.CharField(max_length=50) + status = models.CharField(max_length=20, default='NOT_STARTED') + complexity = models.CharField(max_length=10, default='LOW') + deadline = models.DateField() + iso_week = models.IntegerField() + is_archived = models.BooleanField(default=False) + karma_awarded = models.IntegerField(default=0) + output_link = models.URLField(max_length=500, null=True, blank=True) + remark = models.TextField(null=True, blank=True) + is_verified = models.BooleanField(default=False) + verified_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='intern_task_verified', db_column='verified_by_id') + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_task_created', db_column='created_by') + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_task_updated', db_column='updated_by') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'intern_task' + + +class InternDailyTimesheet(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_daily_timesheets', db_column='user_id') + entry_date = models.DateField() + # task: JSON array of {task_id, title, status, remark} — updated per submission + task = models.JSONField(null=True, blank=True) + description = models.TextField() + hours = models.DecimalField(max_digits=4, decimal_places=2) + blockers = models.TextField(null=True, blank=True) + end_of_day_note = models.TextField(null=True, blank=True) + edit_reason = models.CharField(max_length=300, null=True, blank=True) + status = models.CharField(max_length=15, default='PENDING') + reviewed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='intern_timesheet_reviews', db_column='reviewed_by') + reviewed_at = models.DateTimeField(null=True, blank=True) + review_note = models.CharField(max_length=300, null=True, blank=True) + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_timesheet_created', db_column='created_by') + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_timesheet_updated', db_column='updated_by') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'intern_daily_timesheet' + + +class InternWeeklyReview(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_weekly_reviews', db_column='user_id') + iso_year = models.SmallIntegerField() + iso_week = models.IntegerField() + week_start_date = models.DateField() + week_end_date = models.DateField() + team = models.CharField(max_length=75) + is_on_leave = models.BooleanField(default=False) + tasks_assigned = models.JSONField(default=dict) + # tasks_completed: JSON array of {task_id, title, category, complexity, deadline, final_status, output_link} + tasks_completed = models.JSONField(null=True, blank=True) + weekly_review = models.TextField() + task_remarks = models.JSONField(null=True, blank=True) + hours_committed = models.DecimalField(max_digits=5, decimal_places=2) + blockers = models.TextField(null=True, blank=True) + leave_days = models.TextField(null=True, blank=True) + suggestions = models.TextField(null=True, blank=True) + is_late = models.BooleanField(default=False) + status = models.CharField(max_length=15, default='PENDING') + reviewed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='intern_weekly_reviews_reviewed', db_column='reviewed_by') + reviewed_at = models.DateTimeField(null=True, blank=True) + review_note = models.CharField(max_length=300, null=True, blank=True) + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_weekly_review_created', db_column='created_by') + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_weekly_review_updated', db_column='updated_by') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'intern_weekly_review' + + +class InternLeaveRequest(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_leave_requests', db_column='user_id') + leave_type = models.CharField(max_length=20) + start_date = models.DateField() + end_date = models.DateField() + duration_days = models.SmallIntegerField() + reason = models.TextField() + status = models.CharField(max_length=15, default='PENDING') + reviewed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='intern_leave_reviews', db_column='reviewed_by') + reviewed_at = models.DateTimeField(null=True, blank=True) + review_note = models.CharField(max_length=300, null=True, blank=True) + created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_leave_created', db_column='created_by') + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='intern_leave_updated', db_column='updated_by') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'intern_leave_request' + + +class InternGuildMinute(models.Model): + """ + Stores daily guild minutes uploaded by an Intern Lead. + Each entry is scoped to a specific guild and date. + """ + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + guild = models.CharField(max_length=75) + date = models.DateField() + title = models.CharField(max_length=200) + minutes = models.TextField() + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='intern_minutes_created', db_column='created_by' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name='intern_minutes_updated', db_column='updated_by' + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'intern_guild_minute' + diff --git a/db/job.py b/db/job.py new file mode 100644 index 000000000..bcd164b39 --- /dev/null +++ b/db/job.py @@ -0,0 +1,55 @@ +import uuid +from django.db import models + +class CompanyJob(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + company = models.ForeignKey('db.Company', on_delete=models.CASCADE, related_name='jobs') + title = models.CharField(max_length=75) + experience = models.CharField(max_length=20, null=True, blank=True) + job_description = models.TextField(null=True, blank=True) + location = models.CharField(max_length=75, null=True, blank=True) + salary_range = models.CharField(max_length=36, null=True, blank=True) + job_type = models.CharField(max_length=20) # Enum: Hybrid, Full-Time, Remote, Part-Time, Internship, Gig + status = models.CharField(max_length=20, default='Draft') # Enum: Draft, Active, Closed, Expired + is_deleted = models.BooleanField(default=False) + total_views = models.IntegerField(default=0) + duration_value = models.PositiveSmallIntegerField(null=True, blank=True) + duration_unit = models.CharField(max_length=20, null=True, blank=True) # Enum: days, weeks, months + hourly_rate = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + deliverables = models.JSONField(null=True, blank=True) + stipend = models.CharField(max_length=75, null=True, blank=True) + certificate_provided = models.CharField(max_length=3, null=True, blank=True) # Enum: Yes, No + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'company_jobs' + +class CompanyJobRule(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + job = models.ForeignKey(CompanyJob, on_delete=models.CASCADE, related_name='rules') + rule_type = models.CharField(max_length=50) # min_karma, max_karma, min_level, max_level, skill, degree, etc. + rule_value = models.CharField(max_length=150) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'company_job_rules' + +class UserJobApplication(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + job = models.ForeignKey(CompanyJob, on_delete=models.CASCADE, related_name='applications') + user = models.ForeignKey('db.User', on_delete=models.CASCADE, related_name='company_job_applications') + resume_link = models.TextField(null=True, blank=True) + cover_letter = models.TextField(null=True, blank=True) + status = models.CharField(max_length=20, default='Pending') # Pending, In-Review, Shortlisted, Interview, Rejected, Selected + rejection_reason = models.TextField(null=True, blank=True) + applied_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'user_job_application' + unique_together = ('job', 'user') diff --git a/db/learning_circle.py b/db/learning_circle.py index 004f3259e..59063a8aa 100644 --- a/db/learning_circle.py +++ b/db/learning_circle.py @@ -16,9 +16,6 @@ class LearningCircle(models.Model): org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="learning_circle_org_id", null=True, blank=True) description = models.CharField(max_length=500, blank=True, null=True) title = models.CharField(max_length=100, blank=False, null=False) - is_recurring = models.BooleanField(default=True, null=False) - recurrence_type = models.CharField(max_length=10, blank=True, null=True) - recurrence = models.IntegerField(blank=True, null=True) created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="created_by", related_name="learning_circle_created_by") created_at = models.DateTimeField(auto_now=True) @@ -34,7 +31,9 @@ class UserCircleLink(models.Model): circle = models.ForeignKey(LearningCircle, on_delete=models.CASCADE, related_name='user_circle_link_circle') lead = models.BooleanField(default=False) is_invited = models.BooleanField(default=False) - accepted = models.BooleanField() + invited_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, + related_name='user_circle_link_invited_by', db_column='invited_by') + accepted = models.BooleanField(null=True, blank=True) accepted_at = models.DateTimeField(blank=True, null=True) created_at = models.DateTimeField(auto_now=True) @@ -53,6 +52,9 @@ class CircleMeetingLog(models.Model): title = models.CharField(max_length=100, blank=False, null=False) description = models.CharField(max_length=1000, blank=False, null=False) mode = models.CharField(max_length=10, blank=False, null=False, choices=MODE_CHOICES) + is_recurring = models.BooleanField(default=True, null=False) + recurrence_type = models.CharField(max_length=10, blank=True, null=True) + recurrence = models.IntegerField(blank=True, null=True) is_report_needed = models.BooleanField(default=True, null=False) report_description = models.CharField(max_length=1000, blank=True, null=True) coord_x = models.FloatField(blank=False, null=False) diff --git a/db/mentor.py b/db/mentor.py new file mode 100644 index 000000000..5b1b91234 --- /dev/null +++ b/db/mentor.py @@ -0,0 +1,245 @@ +import uuid +from django.db import models +from django.conf import settings +from db.user import User +from db.task import InterestGroup + +class MentorshipSession(models.Model): + class Mode(models.TextChoices): + ONLINE = 'ONLINE', 'Online' + OFFLINE = 'OFFLINE', 'Offline' + HYBRID = 'HYBRID', 'Hybrid' + + class Status(models.TextChoices): + REQUESTED = 'REQUESTED', 'Requested' + SCHEDULED = 'SCHEDULED', 'Scheduled' + PENDING_APPROVAL = 'PENDING_APPROVAL', 'Pending Approval' + COMPLETED = 'COMPLETED', 'Completed' + CANCELLED = 'CANCELLED', 'Cancelled' + REJECTED = 'REJECTED', 'Rejected' + + class SessionType(models.TextChoices): + IG_SESSION = 'ig_session', 'IG Session' + CAMPUS_SESSION = 'campus_session', 'Campus Session' + COMPANY_SESSION = 'company_session', 'Company Session' + + class RecurrenceType(models.TextChoices): + DAILY = 'DAILY', 'Daily' + WEEKLY = 'WEEKLY', 'Weekly' + MONTHLY = 'MONTHLY', 'Monthly' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + session_type = models.CharField(max_length=20, choices=SessionType.choices, default=SessionType.IG_SESSION) + entity_id = models.CharField(max_length=36, blank=True, null=True) + title = models.CharField(max_length=150) + description = models.TextField(blank=True, null=True) + mode = models.CharField(max_length=10, choices=Mode.choices, default=Mode.ONLINE) + starts_at = models.DateTimeField() + ends_at = models.DateTimeField() + meeting_link = models.CharField(max_length=500, blank=True, null=True) + venue = models.CharField(max_length=255, blank=True, null=True) + status = models.CharField(max_length=20, choices=Status.choices) + max_participants = models.IntegerField(blank=True, null=True) + is_deleted = models.BooleanField(default=False) + + is_recurring = models.BooleanField(default=False) + recurrence_type = models.CharField(max_length=20, choices=RecurrenceType.choices, blank=True, null=True) + recurrence_interval = models.IntegerField(blank=True, null=True) + recurrence_end_date = models.DateField(blank=True, null=True) + parent_session = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, db_column='parent_session_id', related_name='child_sessions') + + created_by = models.ForeignKey(User, on_delete=models.CASCADE, db_column="created_by", related_name="mentorship_session_created_by") + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.CASCADE, db_column="updated_by", related_name="mentorship_session_updated_by") + updated_at = models.DateTimeField(auto_now=True) + + approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, db_column="approved_by", related_name="mentorship_session_approved_by") + approved_at = models.DateTimeField(blank=True, null=True) + + # Populated when a student creates the session request. NULL for mentor-created sessions. + requested_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, db_column="requested_by", related_name="mentorship_session_requested_by") + + class Meta: + managed = False + db_table = 'mentorship_session' + +class MentorAvailabilitySlot(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + mentor_user = models.ForeignKey(User, on_delete=models.CASCADE, db_column="mentor_user_id", related_name="availability_slots") + ig = models.ForeignKey(InterestGroup, on_delete=models.SET_NULL, null=True, db_column="ig_id", related_name="availability_slots") + weekday = models.SmallIntegerField() # 1=Mon ... 7=Sun + start_time = models.TimeField() + end_time = models.TimeField() + timezone = models.CharField(max_length=64, default="Asia/Kolkata") + is_active = models.BooleanField(default=True) + valid_from = models.DateField(blank=True, null=True) + valid_to = models.DateField(blank=True, null=True) + + created_by = models.ForeignKey(User, on_delete=models.CASCADE, db_column="created_by", related_name="mentor_availability_slot_created_by") + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.CASCADE, db_column="updated_by", related_name="mentor_availability_slot_updated_by") + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'mentor_availability_slot' + +class MentorshipSessionUserLink(models.Model): + class ParticipantRole(models.TextChoices): + MENTOR = 'MENTOR', 'Mentor' + MENTEE = 'MENTEE', 'Mentee' + CO_MENTOR = 'CO_MENTOR', 'Co-Mentor' + + class AttendanceStatus(models.TextChoices): + INVITED = 'INVITED', 'Invited' + ATTENDED = 'ATTENDED', 'Attended' + ABSENT = 'ABSENT', 'Absent' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + session = models.ForeignKey(MentorshipSession, on_delete=models.CASCADE, db_column="session_id", related_name="participant_links") + user = models.ForeignKey(User, on_delete=models.CASCADE, db_column="user_id", related_name="mentorship_session_links") + participant_role = models.CharField(max_length=20, choices=ParticipantRole.choices) + attendance_status = models.CharField(max_length=20, choices=AttendanceStatus.choices, default=AttendanceStatus.INVITED) + progress_note = models.CharField(max_length=500, blank=True, null=True) + feedback = models.TextField(blank=True, null=True) + contributed_minutes = models.IntegerField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'mentorship_session_user_link' + unique_together = [('session', 'user', 'participant_role')] + + +class IgOpportunity(models.Model): + + class OpportunityType(models.TextChoices): + CHALLENGE = 'CHALLENGE', 'Challenge' + INTERNSHIP = 'INTERNSHIP', 'Internship' + HACKATHON = 'HACKATHON', 'Hackathon' + JOB = 'JOB', 'Job' + + class Status(models.TextChoices): + DRAFT = 'DRAFT', 'Draft' + PUBLISHED = 'PUBLISHED', 'Published' + CLOSED = 'CLOSED', 'Closed' + ARCHIVED = 'ARCHIVED', 'Archived' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + ig = models.ForeignKey( + InterestGroup, on_delete=models.SET_NULL, + null=True, blank=True, + db_column='ig_id', related_name='ig_opportunities' + ) + # Org-scoped opportunity (COMPANY_MENTOR / CAMPUS_MENTOR). + # Either ig or org must be set; both can be set for campus+IG opps. + org = models.ForeignKey( + 'db.Organization', + on_delete=models.SET_NULL, + null=True, blank=True, + db_column='org_id', + related_name='org_opportunities' + ) + type = models.CharField(max_length=15, choices=OpportunityType.choices) + title = models.CharField(max_length=150) + description = models.TextField() + eligibility = models.TextField(null=True, blank=True) + application_url = models.CharField(max_length=500, null=True, blank=True) + starts_at = models.DateTimeField(null=True, blank=True) + ends_at = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=10, choices=Status.choices, default=Status.DRAFT) + created_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', related_name='ig_opportunity_created_by' + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey( + User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', related_name='ig_opportunity_updated_by' + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'ig_opportunity' + + +class MentorKarmaAward(models.Model): + """ + Tracks karma awarded by an admin to a mentor after a completed session. + One award per (session, mentor) pair — enforced by unique_together. + The kal_id FK is set after the KarmaActivityLog row is created. + """ + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + session = models.ForeignKey( + MentorshipSession, on_delete=models.CASCADE, + db_column='session_id', related_name='karma_awards' + ) + mentor = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='mentor_id', related_name='mentor_karma_awards' + ) + karma = models.IntegerField() + note = models.CharField(max_length=500, null=True, blank=True) + awarded_by = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='awarded_by', related_name='mentor_karma_awards_given' + ) + awarded_at = models.DateTimeField() + # Linked KarmaActivityLog row — set after KAL is created + kal_id = models.CharField(max_length=36, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'mentor_karma_award' + unique_together = [('session', 'mentor')] + + +class SystemActionLog(models.Model): + + class ActionType(models.TextChoices): + PERSONA_SWITCH = 'PERSONA_SWITCH', 'Persona Switch' + MENTOR_VERIFY = 'MENTOR_VERIFY', 'Mentor Verify' + TASK_REVIEW = 'TASK_REVIEW', 'Task Review' + EVENT_REVIEW = 'EVENT_REVIEW', 'Event Review' + SESSION_CREATE = 'SESSION_CREATE', 'Session Create' + SESSION_UPDATE = 'SESSION_UPDATE', 'Session Update' + SESSION_STATUS = 'SESSION_STATUS', 'Session Status' + KARMA_AWARD = 'KARMA_AWARD', 'Karma Award' + MANUAL_HOURS_LOG = 'MANUAL_HOURS_LOG', 'Manual Hours Log' + IG_CONTENT_UPDATE = 'IG_CONTENT_UPDATE', 'IG Content Update' + OPPORTUNITY_POST = 'OPPORTUNITY_POST', 'Opportunity Post' + INTERN_TASK_UPDATE = 'INTERN_TASK_UPDATE', 'Intern Task Update' + INTERN_LEAVE_REQUEST = 'INTERN_LEAVE_REQUEST', 'Intern Leave Request' + INTERN_LEAVE_REVIEW = 'INTERN_LEAVE_REVIEW', 'Intern Leave Review' + INTERN_TIMESHEET_EDIT = 'INTERN_TIMESHEET_EDIT', 'Intern Timesheet Edit' + INTERN_GUILD_REASSIGN = 'INTERN_GUILD_REASSIGN', 'Intern Guild Reassign' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + action_type = models.CharField(max_length=25, choices=ActionType.choices) + actor_user = models.ForeignKey( + User, on_delete=models.CASCADE, + db_column='actor_user_id', related_name='system_actions_as_actor' + ) + subject_user = models.ForeignKey( + User, on_delete=models.SET_NULL, + null=True, blank=True, + db_column='subject_user_id', related_name='system_actions_as_subject' + ) + ig = models.ForeignKey( + InterestGroup, on_delete=models.SET_NULL, + null=True, blank=True, db_column='ig_id', + related_name='system_action_logs' + ) + # Generic entity reference (e.g. 'events', 'mentorship_session') + entity_name = models.CharField(max_length=50) + entity_id = models.CharField(max_length=36) + old_data = models.JSONField(null=True, blank=True) + new_data = models.JSONField(null=True, blank=True) + remarks = models.CharField(max_length=500, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'system_action_log' diff --git a/db/models.py b/db/models.py new file mode 100644 index 000000000..43a479f43 --- /dev/null +++ b/db/models.py @@ -0,0 +1,37 @@ +"""Model registry for the ``db`` app. + +This app keeps its models split across sibling modules (``db/task.py``, +``db/company.py``, …) instead of a single file. Django automatically imports +``.models`` during ``apps.populate()`` (the canonical model-registration +phase, before ``AppConfig.ready()``), so importing every model module here is +what registers all models and lets lazy string FK references such as +``'db.Company'`` / ``'db.InterestGroup'`` resolve. + +When you add a new model module under ``db/``, add it to the import list below. +""" + +# noqa: F401 — imported for the side effect of registering models. +from db import ( # noqa: F401 + achievement, + campus, + comic, + comic_comment, + company, + donation, + donor, + events, + hackathon, + integrations, + launchpad, + learning_circle, + mentor, + notification, + organization, + projects, + settings, + skill, + task, + url_shortener, + user, + job, +) diff --git a/db/notification.py b/db/notification.py index 68211804f..cd133620d 100644 --- a/db/notification.py +++ b/db/notification.py @@ -23,3 +23,32 @@ class Meta: managed = False db_table = "notification" ordering = ["created_at"] + + +class BroadcastNotification(models.Model): + """ + One row per group-wide broadcast announcement. + Recipients are resolved dynamically at read time via target_type + target_id. + + target_type values: + 'campus' → all users linked to target_id (org_id) + 'interest_group' → all learners with an active link to target_id (ig_id) + 'campus_ig' → all learners in a campus-IG chapter (target_id = campus_ig composite id) + 'event_interest' → all users who expressed interest in target_id (event_id) + 'event_coowners' → event creator + all co-owners of target_id (event_id) + 'global' → all active users (target_id is NULL) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + title = models.CharField(max_length=50) + description = models.CharField(max_length=200) + url = models.CharField(max_length=100, blank=True, null=True) + target_type = models.CharField(max_length=30) + target_id = models.CharField(max_length=36, blank=True, null=True) + created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="created_by", related_name="created_broadcasts") + created_at = models.DateTimeField() + expires_at = models.DateTimeField() + + class Meta: + managed = False + db_table = "broadcast_notification" + ordering = ["-created_at"] diff --git a/db/organization.py b/db/organization.py index 690dbf364..c1400e81e 100644 --- a/db/organization.py +++ b/db/organization.py @@ -72,7 +72,7 @@ class Meta: class OrgAffiliation(models.Model): - id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4()) + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) title = models.CharField(max_length=75) updated_by = models.ForeignKey( User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='updated_by', related_name='org_affiliation_updated_by') @@ -87,7 +87,7 @@ class Meta: class Organization(models.Model): - id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4()) + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) title = models.CharField(max_length=100) code = models.CharField(unique=True, max_length=12) org_type = models.CharField(max_length=25) @@ -106,7 +106,7 @@ class Meta: class Department(models.Model): - id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4()) + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) title = models.CharField(max_length=100) updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='updated_by', related_name='department_updated_by') @@ -121,7 +121,7 @@ class Meta: class College(models.Model): - id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4()) + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) level = models.IntegerField(default=0) org = models.OneToOneField(Organization, on_delete=models.CASCADE, related_name='college_org') updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='updated_by', related_name='college_updated_by') @@ -168,7 +168,7 @@ class Meta: class OrgKarmaType(models.Model): - id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4()) + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) title = models.CharField(max_length=75) karma = models.IntegerField() description = models.CharField(max_length=200, blank=True, null=True) @@ -185,7 +185,7 @@ class Meta: class OrgKarmaLog(models.Model): - id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4()) + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) org = models.ForeignKey(Organization, models.DO_NOTHING, related_name='org_karma_log_org') karma = models.IntegerField() type = models.ForeignKey(OrgKarmaType, models.DO_NOTHING, db_column='type', related_name='org_karma_log_type') @@ -236,3 +236,41 @@ class UnverifiedOrganization(models.Model): class Meta: managed = False db_table = 'unverified_organization' + + +class EnablerCampusNote(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + enabler = models.ForeignKey(User, on_delete=models.CASCADE, related_name='enabler_notes') + campus = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='enabler_campus_notes') + note = models.TextField() + status = models.CharField(max_length=20, default='open') + priority = models.CharField(max_length=20, default='medium') + follow_up_date = models.DateField(blank=True, null=True) + updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='updated_by', related_name='enabler_note_updated_by') + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='created_by', related_name='enabler_note_created_by') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'enabler_campus_note' + + +class CollegeShowcase(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + org = models.OneToOneField(Organization, on_delete=models.CASCADE, related_name='showcase') + about = models.TextField(null=True, blank=True) + hero_image = models.CharField(max_length=255, null=True, blank=True) + highlights = models.JSONField(default=list) + gallery = models.JSONField(default=list) + testimonials = models.JSONField(default=list) + contact_email = models.CharField(max_length=255, null=True, blank=True) + contact_phone = models.CharField(max_length=20, null=True, blank=True) + updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='updated_by', related_name='college_showcase_updated_by') + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='created_by', related_name='college_showcase_created_by') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = 'college_showcase' diff --git a/db/projects.py b/db/projects.py index acc7782d9..2b2ff8dc1 100644 --- a/db/projects.py +++ b/db/projects.py @@ -1,62 +1,152 @@ -import uuid +import uuid from django.db import models from db.user import User -from django.conf import settings +from db.skill import Skill + class Project(models.Model): + STATUS_CHOICES = [("draft", "Draft"), ("published", "Published"), ("archived", "Archived")] id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) - logo = models.ImageField(upload_to='projects/logos/', null=True, blank=True) - title = models.CharField(max_length=50, blank=False) - description = models.CharField(max_length=200, blank=False) - link = models.URLField() - contributors = models.TextField(null=True, blank=True) + logo = models.ImageField(upload_to="projects/logos/", null=True, blank=True) + title = models.CharField(max_length=50) + description = models.TextField() + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="published") + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="created_projects", db_column="created_by", + ) + updated_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="updated_projects", db_column="updated_by", + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - # created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), related_name='created_projects', db_column='created_by') - # updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), related_name='updated_projects', db_column='updated_by') class Meta: managed = False db_table = "projects" - ordering = ["created_at"] - + ordering = ["-created_at"] + + class ProjectImage(models.Model): id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) - project = models.ForeignKey(Project, related_name='images', on_delete=models.CASCADE) - image = models.ImageField(upload_to='projects/images/', null=True, blank=True) + project = models.ForeignKey(Project, related_name="images", on_delete=models.CASCADE) + image = models.ImageField(upload_to="projects/images/", null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: + managed = False db_table = "project_images" - + + +class ProjectLink(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + project = models.ForeignKey(Project, related_name="links", on_delete=models.CASCADE) + label = models.CharField(max_length=50) + url = models.CharField(max_length=500) + position = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = "project_links" + ordering = ["position", "created_at"] + + +class ProjectSkillLink(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + project = models.ForeignKey(Project, related_name="skill_links", on_delete=models.CASCADE) + skill = models.ForeignKey(Skill, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = "project_skill_link" + unique_together = ("project", "skill") + + class Comment(models.Model): id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) comment = models.TextField() - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments') - # user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='comments') + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="comments") + user = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="project_comments", db_column="user_id", + ) + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="created_project_comments", db_column="created_by", + ) + updated_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="updated_project_comments", db_column="updated_by", + ) created_at = models.DateTimeField(auto_now_add=True) - # created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), related_name='created_comments', db_column='created_by') updated_at = models.DateTimeField(auto_now=True) - # updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), related_name='updated_comments', db_column='updated_by') class Meta: managed = False db_table = "projects_comments" ordering = ["created_at"] - + + class Vote(models.Model): - VOTE_CHOICES = [('upvote', 'Upvote'), ('downvote', 'Downvote')] + VOTE_CHOICES = [("upvote", "Upvote"), ("downvote", "Downvote")] id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) vote = models.CharField(max_length=10, choices=VOTE_CHOICES) - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='votes') - # user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='votes') + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="votes") + user = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="project_votes", db_column="user_id", + ) + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="created_project_votes", db_column="created_by", + ) + updated_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="updated_project_votes", db_column="updated_by", + ) created_at = models.DateTimeField(auto_now_add=True) - # created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), related_name='created_votes', db_column='created_by') updated_at = models.DateTimeField(auto_now=True) - # updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), related_name='updated_votes', db_column='updated_by') class Meta: managed = False db_table = "projects_votes" - ordering = ["created_at"] \ No newline at end of file + ordering = ["created_at"] + unique_together = ("user", "project") + + +class ProjectMember(models.Model): + """Unified team member: either `user` (linked mulearn user) or + `external_name` (plain text) is set. Enforced at DB level via CHECK constraint.""" + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="members") + user = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="project_memberships", db_column="user_id", + null=True, blank=True, + ) + external_name = models.CharField(max_length=100, null=True, blank=True) + role = models.CharField(max_length=50, null=True, blank=True) + created_by = models.ForeignKey( + User, on_delete=models.CASCADE, + related_name="created_project_members", db_column="created_by", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = "project_members" + ordering = ["created_at"] + constraints = [ + models.CheckConstraint( + check=( + (models.Q(user__isnull=False) & models.Q(external_name__isnull=True)) | + (models.Q(user__isnull=True) & models.Q(external_name__isnull=False)) + ), + name="chk_project_member_identity", + ), + ] + unique_together = ("user", "project") diff --git a/db/settings.py b/db/settings.py index e69bc548a..6bad30744 100644 --- a/db/settings.py +++ b/db/settings.py @@ -9,7 +9,7 @@ class Device(models.Model): id = models.CharField(primary_key=True, max_length=36) browser = models.CharField(max_length=36, null=False) os = models.CharField(max_length=36, null=False) - user_id = models.ForeignKey(User, on_delete=models.CASCADE) + user_id = models.ForeignKey(User, on_delete=models.CASCADE, db_column="user_id") last_log_in = models.DateTimeField(null=False) class Meta: diff --git a/db/skill.py b/db/skill.py new file mode 100644 index 000000000..81e091f55 --- /dev/null +++ b/db/skill.py @@ -0,0 +1,76 @@ +import uuid + +from django.db import models +from django.conf import settings + +from .user import User +from .task import TaskList + + +class Skill(models.Model): + """ + Skill model for categorizing tasks. + Skills can be used to create skill-based achievements. + """ + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + name = models.CharField(max_length=75, unique=True) + code = models.CharField(max_length=20, unique=True) + description = models.TextField(blank=True, null=True) + icon = models.CharField(max_length=100, blank=True, null=True) + is_active = models.BooleanField(default=True) + updated_by = models.ForeignKey( + User, + on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column="updated_by", + related_name="skill_updated_by", + ) + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey( + User, + on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column="created_by", + related_name="skill_created_by", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = "skill" + + def __str__(self): + return self.name + + +class TaskSkillLink(models.Model): + """ + Junction table linking tasks to skills (many-to-many). + A task can have multiple skills, and a skill can be associated with multiple tasks. + """ + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + task = models.ForeignKey( + TaskList, + on_delete=models.CASCADE, + related_name="skill_links", + db_column="task_id", + ) + skill = models.ForeignKey( + "Skill", + on_delete=models.CASCADE, + related_name="task_links", + db_column="skill_id", + ) + created_by = models.ForeignKey( + User, + on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column="created_by", + related_name="task_skill_link_created_by", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = "task_skill_link" + unique_together = [["task", "skill"]] + + def __str__(self): + return f"{self.task.title} - {self.skill.name}" diff --git a/db/task.py b/db/task.py index b3a07848d..c5b1db221 100644 --- a/db/task.py +++ b/db/task.py @@ -41,6 +41,28 @@ class InterestGroup(models.Model): code = models.CharField(max_length=10, unique=True) icon = models.CharField(max_length=10) category =models.CharField(max_length=20,default="others",blank=False,null=False) + status = models.CharField( + max_length=20, + choices=[ + ('active', 'Active'), + ('requested', 'Requested'), + ('cancelled', 'Cancelled'), + ('rejected', 'Rejected'), + ], + default='requested', + blank=False, + null=False + ) + about = models.TextField(blank=True, null=True) + prerequisites = models.TextField(blank=True, null=True) + career_opportunities = models.TextField(blank=True, null=True) + top_blogs = models.TextField(blank=True, null=True) + people_to_follow = models.TextField(blank=True, null=True) + resource = models.TextField(blank=True, null=True) + leads = models.TextField(blank=True, null=True) + mentors = models.TextField(blank=True, null=True) + thinktank = models.TextField(blank=True, null=True) + office_hours = models.CharField(max_length=200, blank=True, null=True) updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="updated_by", related_name="interest_group_updated_by") updated_at = models.DateTimeField(auto_now=True) @@ -114,6 +136,8 @@ class TaskList(models.Model): level = models.ForeignKey(Level, on_delete=models.CASCADE, null=True) ig = models.ForeignKey(InterestGroup, on_delete=models.CASCADE, null=True, related_name="task_list_ig") event = models.CharField(max_length=50, null=True) + event_fk = models.ForeignKey("db.Event", on_delete=models.SET_NULL, null=True, blank=True, db_column="event_id", + related_name="task_list_events") active = models.BooleanField(default=True) variable_karma = models.BooleanField(default=False) usage_count = models.IntegerField(default=1) @@ -124,9 +148,40 @@ class TaskList(models.Model): created_by = models.ForeignKey(User, models.DO_NOTHING, db_column="created_by", related_name="task_list_created_by") created_at = models.DateTimeField(auto_now_add=True) + # Company task submission & admin approval workflow + APPROVAL_STATUS_CHOICES = [ + ('approved', 'Approved'), + ('pending', 'Pending'), + ('rejected', 'Rejected'), + ] + approval_status = models.CharField( + max_length=20, choices=APPROVAL_STATUS_CHOICES, default='approved' + ) + submitted_by_company = models.ForeignKey( + 'db.Company', on_delete=models.SET_NULL, + null=True, blank=True, db_column='submitted_by_company_id', + related_name='company_submitted_tasks' + ) + rejection_reason = models.TextField(null=True, blank=True) + reviewed_by_admin = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + db_column='reviewed_by_admin_id', related_name='task_list_reviewed_by' + ) + reviewed_at = models.DateTimeField(null=True, blank=True) + + # Mentor task submission tracking + requested_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + db_column='requested_by', related_name='task_list_requested_by' + ) + requested_at = models.DateTimeField(null=True, blank=True) + class Meta: managed = False db_table = "task_list" + indexes = [ + models.Index(fields=['event_fk'], name='idx_task_list_event_id'), + ] class Wallet(models.Model): @@ -161,8 +216,14 @@ class KarmaActivityLog(models.Model): null=True, related_name="karma_activity_log_peer_approved_by") appraiser_approved = models.BooleanField(blank=True, null=True) appraiser_approved_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="appraiser_approved_by", - blank=True, null=True, + blank=True, null=True, related_name="karma_activity_log_appraiser_approved_by") + mentor_review_status = models.CharField(max_length=10, default='PENDING') + mentor_reviewed_by = models.ForeignKey(User, on_delete=models.SET_NULL, db_column="mentor_reviewed_by", + blank=True, null=True, + related_name="karma_activity_log_mentor_reviewed_by") + mentor_reviewed_at = models.DateTimeField(blank=True, null=True) + mentor_review_feedback = models.CharField(max_length=500, blank=True, null=True) updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="updated_by", related_name="karma_activity_log_updated_by") updated_at = models.DateTimeField(auto_now=True) @@ -208,18 +269,65 @@ class Meta: class UserIgLink(models.Model): + + class AssignmentType(models.TextChoices): + MENTOR = 'MENTOR', 'Mentor' + LEARNER = 'LEARNER', 'Learner' + LEAD = 'LEAD', 'Lead' + MODERATOR = 'MODERATOR', 'Moderator' + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="user_ig_link_user") ig = models.ForeignKey(InterestGroup, on_delete=models.CASCADE, related_name="user_ig_link_ig") + assignment_type = models.CharField( + max_length=15, choices=AssignmentType.choices, default=AssignmentType.LEARNER + ) + is_active = models.BooleanField(default=True) + assigned_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + db_column='assigned_by', related_name='user_ig_link_assigned_by' + ) + unassigned_at = models.DateTimeField(null=True, blank=True) created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="created_by", related_name="user_ig_link_created_by") created_at = models.DateTimeField(auto_now_add=True) + unassigned_at = models.DateTimeField(blank=True, null=True) class Meta: managed = False db_table = "user_ig_link" +class UserIgLvlLink(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="user_ig_lvl_link_user") + ig = models.ForeignKey(InterestGroup, on_delete=models.CASCADE, related_name="user_ig_lvl_link_ig") + level = models.ForeignKey(Level, on_delete=models.CASCADE, related_name="user_ig_lvl_link_level") + updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="updated_by", + related_name="user_ig_lvl_link_updated_by") + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="created_by", + related_name="user_ig_lvl_link_created_by") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = "user_ig_lvl_link" + unique_together = [("user", "ig")] + + +class UserIgLvlLog(models.Model): + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="user_ig_lvl_log_user") + ig = models.ForeignKey(InterestGroup, on_delete=models.CASCADE, related_name="user_ig_lvl_log_ig") + level = models.ForeignKey(Level, on_delete=models.CASCADE, related_name="user_ig_lvl_log_level") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = "user_ig_lvl_log" + + class VoucherLog(models.Model): id = models.CharField(primary_key=True, max_length=36) code = models.CharField(unique=True, max_length=255) @@ -242,16 +350,46 @@ class Meta: managed = False db_table = "voucher_log" +class Category(models.Model): + class EntityType(models.TextChoices): + EVENT = 'event', 'Event' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + entity_id = models.CharField(max_length=36) + entity_type = models.CharField(max_length=20, choices=EntityType.choices) + updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="updated_by", + related_name="category_updated_by") + updated_at = models.DateTimeField(auto_now=True) + created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="created_by", + related_name="category_created_by") + created_at = models.DateTimeField(auto_now_add=True) + class Meta: + managed = False + db_table = "categories" + indexes = [ + models.Index(fields=['entity_id', 'entity_type'], name='idx_categories_entity'), + ] + -class Events(models.Model): - id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) - name = models.CharField(max_length=75) - description = models.CharField(max_length=200, null=True) - updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="updated_by", related_name="event_updated_by") - updated_at = models.DateTimeField(auto_now=True) - created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="created_by", related_name="event_created_by") - created_at = models.DateTimeField(auto_now_add=True) +class TaskReport(models.Model): + id = models.CharField(primary_key=True, max_length=36) + reporter = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_report_reporter") + offender = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_report_offender") + message_id = models.CharField(max_length=100) + reason = models.CharField(max_length=500) + proof_link = models.CharField(max_length=255, blank=True, null=True) + status = models.CharField(max_length=20, default="PENDING") + created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="created_by", related_name="task_report_created_by") + created_at = models.DateTimeField(auto_now_add=True) + updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column="updated_by", related_name="task_report_updated_by") + updated_at = models.DateTimeField(auto_now=True) class Meta: managed = False - db_table = "events" + db_table = "task_report" + + + + diff --git a/db/user.py b/db/user.py index ca597df19..a0e51e9ab 100644 --- a/db/user.py +++ b/db/user.py @@ -22,7 +22,7 @@ class User(models.Model): email = models.EmailField(unique=True, max_length=200) password = models.CharField(max_length=200, blank=True, null=True) mobile = models.CharField(unique=True, max_length=15, blank=True, null=True) - gender = models.CharField(max_length=10, blank=True, null=True, choices=[("Male", "Male"), ("Female", "Female")]) + gender = models.CharField(max_length=20, blank=True, null=True, choices=[("Male", "Male"), ("Female", "Female"), ("Other", "Other"), ("Prefer not to say", "Prefer not to say")]) dob = models.DateField(blank=True, null=True) admin = models.BooleanField(default=False) exist_in_guild = models.BooleanField(default=False) @@ -47,6 +47,13 @@ def profile_pic(self): if fs.exists(path): return f"{decouple_config('BE_DOMAIN_NAME')}{fs.url(path)}" + @property + def cover_pic(self): + fs = FileSystemStorage() + path = f'user/cover/{self.id}.png' + if fs.exists(path): + return f"{decouple_config('BE_DOMAIN_NAME')}{fs.url(path)}" + def save(self, *args, **kwargs): if self.muid is None: full_name = self.full_name.replace(" ", "-").lower() @@ -98,16 +105,94 @@ class Meta: class UserMentor(models.Model): + + class MentorTier(models.TextChoices): + IG_MENTOR = 'IG_MENTOR', 'IG Mentor' # linked to specific IG(s) + MENTOR = 'MENTOR', 'Mentor' # platform-wide global mentor + COMPANY_MENTOR = 'COMPANY_MENTOR', 'Company Mentor' # scoped to a Company org + CAMPUS_MENTOR = 'CAMPUS_MENTOR', 'Campus Mentor' # scoped to a College org + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_mentor_user') + + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='user_mentor_user' + ) + about = models.CharField(max_length=1000, blank=True, null=True) + + expertise = models.TextField(blank=True, null=True) + reason = models.CharField(max_length=1000, blank=True, null=True) - hours = models.IntegerField() - updated_by = models.ForeignKey(User, on_delete=models.CASCADE, db_column='updated_by', - related_name='user_mentor_updated_by_set') + + hours = models.PositiveIntegerField(default=0) + + mentor_tier = models.CharField( + max_length=14, # 14 chars fits 'COMPANY_MENTOR' + choices=MentorTier.choices, + default=MentorTier.MENTOR # default changed from IG_MENTOR → MENTOR + ) + + class Status(models.TextChoices): + PENDING = 'PENDING', 'Pending' + APPROVED = 'APPROVED', 'Approved' + REJECTED = 'REJECTED', 'Rejected' + + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING + ) + + # JSON list of IG UUIDs mentor expressed interest in during onboarding. + # On admin approval, UserIgLink rows are auto-created for each. + preferred_ig_ids = models.JSONField(null=True, blank=True) + + # Organisation this mentor row is scoped to. + # NULL for IG_MENTOR and MENTOR (global) tiers. + # Set to a Company org for COMPANY_MENTOR, College org for CAMPUS_MENTOR. + org = models.ForeignKey( + 'db.Organization', + on_delete=models.SET_NULL, + null=True, blank=True, + db_column='org_id', + related_name='org_mentors' + ) + + verified_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + db_column='verified_by', + related_name='user_mentor_verified_by_set' + ) + + verified_at = models.DateTimeField(blank=True, null=True) + + verification_note = models.CharField( + max_length=500, + blank=True, + null=True + ) + + updated_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + db_column='updated_by', + related_name='user_mentor_updated_by_set' + ) + updated_at = models.DateTimeField(blank=True, null=True) - created_by = models.ForeignKey(User, on_delete=models.CASCADE, db_column='created_by', - related_name='user_mentor_created_by_set') + + created_by = models.ForeignKey( + User, + on_delete=models.CASCADE, + db_column='created_by', + related_name='user_mentor_created_by_set' + ) + created_at = models.DateTimeField(blank=True, null=True) class Meta: @@ -115,6 +200,63 @@ class Meta: db_table = 'user_mentor' +class MentorScopeGrant(models.Model): + """ + A single unit of mentor authority: "this mentor may act as X within + scope Y". Additive and independent per (scope_type, scope_id) — granting + or revoking one grant never affects any other grant for the same mentor, + and never touches identity records like UserOrganizationLink. + """ + + class ScopeType(models.TextChoices): + COMPANY_MENTOR = 'COMPANY_MENTOR', 'Company Mentor' + IG_MENTOR = 'IG_MENTOR', 'IG Mentor' + CAMPUS_MENTOR = 'CAMPUS_MENTOR', 'Campus Mentor' + MENTOR = 'MENTOR', 'Mentor' + + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) + + mentor = models.ForeignKey( + UserMentor, + on_delete=models.CASCADE, + db_column='mentor_id', + related_name='scope_grants' + ) + + scope_type = models.CharField(max_length=14, choices=ScopeType.choices) + + # NULL for scope types that aren't org/IG scoped in the future; + # currently always set (org id for COMPANY_MENTOR/CAMPUS_MENTOR, ig id + # for IG_MENTOR). + scope_id = models.CharField(max_length=36, null=True, blank=True) + + is_active = models.BooleanField(default=True) + + granted_by = models.ForeignKey( + User, + on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='granted_by', + related_name='mentor_grants_given' + ) + + granted_at = models.DateTimeField() + + revoked_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + db_column='revoked_by', + related_name='mentor_grants_revoked' + ) + + revoked_at = models.DateTimeField(null=True, blank=True) + + class Meta: + managed = False + db_table = 'mentor_scope_grant' + + class UserReferralLink(models.Model): id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_referral_link_user') @@ -152,7 +294,20 @@ class UserRoleLink(models.Model): id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_role_link_user') role = models.ForeignKey(Role, on_delete=models.CASCADE) + # IG-scoped role support: NULL = global platform role, SET = IG-specific role + ig = models.ForeignKey( + 'db.InterestGroup', on_delete=models.CASCADE, + null=True, blank=True, db_column='ig_id', + related_name='user_role_link_ig' + ) verified = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + is_primary = models.BooleanField(default=False) + revoked_at = models.DateTimeField(null=True, blank=True) + revoked_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + db_column='revoked_by', related_name='user_role_link_revoked_by' + ) created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='created_by', related_name='user_role_link_created_by') created_at = models.DateTimeField(auto_now_add=True) @@ -160,9 +315,6 @@ class UserRoleLink(models.Model): class Meta: managed = False db_table = 'user_role_link' - constraints = [ - models.UniqueConstraint(fields=['role', 'user'], name="UserToRole") - ] class Socials(models.Model): @@ -201,15 +353,69 @@ class Meta: class UserSettings(models.Model): + + class PersonaType(models.TextChoices): + LEARNER = 'learner', 'Learner' + MENTOR = 'mentor', 'Mentor' + id = models.CharField(primary_key=True, max_length=36, default=uuid.uuid4) - user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="user_settings_user") + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name='user_settings_user' + ) + is_public = models.BooleanField(default=False) + is_userterms_approved = models.BooleanField(default=False) - updated_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='updated_by', - related_name='user_settings_updated_by') + + # Active contextual persona state + active_persona = models.CharField( + max_length=10, + choices=PersonaType.choices, + default=PersonaType.LEARNER + ) + + active_role_link = models.ForeignKey( + 'UserRoleLink', + on_delete=models.SET_NULL, + null=True, + blank=True, + db_column='active_role_link_id', + related_name='user_settings_active_role_link' + ) + + active_ig = models.ForeignKey( + 'db.InterestGroup', + on_delete=models.SET_NULL, + null=True, + blank=True, + db_column='active_ig_id', + related_name='user_settings_active_ig' + ) + + last_persona_switched_at = models.DateTimeField( + null=True, + blank=True + ) + + updated_by = models.ForeignKey( + User, + on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='updated_by', + related_name='user_settings_updated_by' + ) + updated_at = models.DateTimeField(auto_now=True) - created_by = models.ForeignKey(User, on_delete=models.SET(settings.SYSTEM_ADMIN_ID), db_column='created_by', - related_name='user_settings_created_by') + + created_by = models.ForeignKey( + User, + on_delete=models.SET(settings.SYSTEM_ADMIN_ID), + db_column='created_by', + related_name='user_settings_created_by' + ) + created_at = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/docs/intern_guild_minutes_api.md b/docs/intern_guild_minutes_api.md new file mode 100644 index 000000000..8d5455a86 --- /dev/null +++ b/docs/intern_guild_minutes_api.md @@ -0,0 +1,285 @@ +# Intern Guild Minutes — API Documentation + +Base path: `/api/v1/dashboard/intern/minutes/` + +All endpoints require a valid **Bearer token** in the `Authorization` header. + +--- + +## Endpoints + +### 1. List All Guild Minutes + +``` +GET /api/v1/dashboard/intern/minutes/ +``` + +**Access:** Intern · Intern Lead · Admin + +**Query Parameters** + +| Parameter | Type | Required | Description | +|-----------|--------|----------|------------------------------------------------| +| `guild` | string | No | Filter by guild name (e.g. `Frontend Guild`) | +| `date` | string | No | Filter by exact date (`YYYY-MM-DD`) | +| `page` | int | No | Page number (default: 1) | +| `perPage` | int | No | Results per page (default: 10) | +| `sortBy` | string | No | Sort field: `date` or `guild` | + +**Success Response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": "Success", + "response": { + "data": [ + { + "id": "uuid", + "guild": "Frontend Guild", + "date": "2026-06-17", + "title": "Daily Standup — June 17", + "minutes": "Discussed sprint goals...", + "created_by_name": "John Doe", + "created_at": "2026-06-17T08:00:00Z", + "updated_at": "2026-06-17T08:00:00Z" + } + ], + "pagination": { + "currentPage": 1, + "totalPages": 3, + "totalItems": 25, + "itemsPerPage": 10 + } + } +} +``` + +--- + +### 2. Get a Specific Guild Minute + +``` +GET /api/v1/dashboard/intern/minutes// +``` + +**Access:** Intern · Intern Lead · Admin + +**Path Parameter** + +| Parameter | Type | Description | +|-------------|--------|---------------------------| +| `minute_id` | string | UUID of the minute record | + +**Success Response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": "Success", + "response": { + "id": "uuid", + "guild": "Backend Guild", + "date": "2026-06-17", + "title": "Backend Sync — June 17", + "minutes": "API review completed...", + "created_by_name": "Jane Smith", + "created_at": "2026-06-17T09:30:00Z", + "updated_at": "2026-06-17T09:30:00Z" + } +} +``` + +**Error Response `400`** (not found) + +```json +{ + "hasError": true, + "message": "Guild minute not found." +} +``` + +--- + +### 3. Upload Guild Minutes + +``` +POST /api/v1/dashboard/intern/minutes/ +``` + +**Access:** Intern Lead · Admin + +**Request Body** (`application/json`) + +| Field | Type | Required | Description | +|-----------|--------|----------|-----------------------------------------------------------------------------------| +| `guild` | string | Yes | One of: `Frontend Guild`, `Backend Guild`, `Design Guild`, `Mobile Guild` | +| `date` | string | Yes | Date of the meeting (`YYYY-MM-DD`) | +| `title` | string | Yes | Short title / heading for the minutes (max 200 chars) | +| `minutes` | string | Yes | Full minutes content (plain text or markdown) | + +**Example Request** + +```json +{ + "guild": "Frontend Guild", + "date": "2026-06-17", + "title": "Daily Standup — June 17", + "minutes": "Attendees: Alice, Bob, Carol\n\n- Completed navbar redesign\n- Working on mobile responsiveness\n- Blocker: API contract not finalized" +} +``` + +**Success Response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": "Guild minutes uploaded successfully.", + "response": { + "id": "generated-uuid" + } +} +``` + +**Error Response `400`** (validation) + +```json +{ + "hasError": true, + "response": { + "guild": ["\"Invalid Guild\" is not a valid choice."], + "minutes": ["Minutes content cannot be blank."] + } +} +``` + +--- + +### 4. Update Guild Minutes + +``` +PUT /api/v1/dashboard/intern/minutes// +``` + +**Access:** Intern Lead · Admin + +**Path Parameter** + +| Parameter | Type | Description | +|-------------|--------|---------------------------| +| `minute_id` | string | UUID of the minute record | + +**Request Body** — same schema as POST (all fields required) + +```json +{ + "guild": "Frontend Guild", + "date": "2026-06-17", + "title": "Daily Standup — June 17 (revised)", + "minutes": "Updated minutes content..." +} +``` + +**Success Response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": "Guild minutes updated successfully." +} +``` + +**Error Response `400`** (not found) + +```json +{ + "hasError": true, + "message": "Guild minute not found." +} +``` + +--- + +### 5. Delete Guild Minutes + +``` +DELETE /api/v1/dashboard/intern/minutes// +``` + +**Access:** Intern Lead · Admin + +**Path Parameter** + +| Parameter | Type | Description | +|-------------|--------|---------------------------| +| `minute_id` | string | UUID of the minute record | + +**Success Response `200`** + +```json +{ + "hasError": false, + "statusCode": 200, + "message": "Guild minutes deleted successfully." +} +``` + +**Error Response `400`** (not found) + +```json +{ + "hasError": true, + "message": "Guild minute not found." +} +``` + +--- + +## Role Summary + +| Method | Endpoint | Intern | Intern Lead | Admin | +|----------|-------------------------------------------------------|:------:|:-----------:|:-----:| +| `GET` | `/api/v1/dashboard/intern/minutes/` | ✅ | ✅ | ✅ | +| `GET` | `/api/v1/dashboard/intern/minutes//` | ✅ | ✅ | ✅ | +| `POST` | `/api/v1/dashboard/intern/minutes/` | ❌ | ✅ | ✅ | +| `PUT` | `/api/v1/dashboard/intern/minutes//` | ❌ | ✅ | ✅ | +| `DELETE` | `/api/v1/dashboard/intern/minutes//` | ❌ | ✅ | ✅ | + +--- + +## Valid Guild Values + +| Value | +|------------------| +| `Frontend Guild` | +| `Backend Guild` | +| `Design Guild` | +| `Mobile Guild` | + +--- + +## Related Change — Manage Interns: Leave API + +**Endpoint:** `GET /api/v1/dashboard/manage-interns/leave/?page=1&perPage=10` + +`user_muid` has been added to each leave record in the response: + +```diff + { + "id": "uuid", + "user": "user-uuid", + "user_name": "John Doe", ++ "user_muid": "johndoe@mulearn", + "leave_type": "SICK", + "start_date": "2026-06-18", + "end_date": "2026-06-19", + "reason": "Fever", + "status": "PENDING", + "review_note": null, + "created_at": "2026-06-17T10:00:00Z" + } +``` diff --git a/mu_celery/achievement_cron.py b/mu_celery/achievement_cron.py new file mode 100644 index 000000000..6e8304b20 --- /dev/null +++ b/mu_celery/achievement_cron.py @@ -0,0 +1,214 @@ +""" +Achievement Cron Tasks + +For correctness, not primary logic: +- Daily streak evaluation +- Missed event backfill +- Data integrity checks + +Schedule these via Celery Beat in settings.py +""" +import uuid +from datetime import datetime, date, timedelta +from celery import shared_task +from django.db import models +import logging + +logger = logging.getLogger(__name__) + + +@shared_task +def evaluate_daily_streaks(): + """ + Daily cron: Update all user streaks based on yesterday's activity. + Run at 00:05 UTC daily. + """ + from db.achievement import UserDailyActivity, UserStreak + + yesterday = date.today() - timedelta(days=1) + + # Get all users who had activity yesterday + active_users = list( + UserDailyActivity.objects.filter(activity_date=yesterday).values_list( + "user_id", flat=True + ) + ) + + # Get all users with existing streaks + all_streak_users = list( + UserStreak.objects.values_list("user_id", flat=True).distinct() + ) + + # Process active users - increment streak + for user_id in active_users: + _update_streak(user_id, yesterday, active=True) + + # Process inactive users - reset streak + inactive_users = set(all_streak_users) - set(active_users) + for user_id in inactive_users: + _update_streak(user_id, yesterday, active=False) + + logger.info( + f"Streak evaluation complete. Active: {len(active_users)}, Reset: {len(inactive_users)}" + ) + return {"active": len(active_users), "reset": len(inactive_users)} + + +def _update_streak(user_id: str, activity_date: date, active: bool): + """Update user streak based on activity""" + from db.achievement import UserStreak + + streak_types = ["daily_task", "daily_login"] + + for streak_type in streak_types: + streak, created = UserStreak.objects.get_or_create( + user_id=user_id, + streak_type=streak_type, + defaults={ + "id": str(uuid.uuid4()), + "current_streak": 0, + "longest_streak": 0, + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + + if active: + # Check if streak continues (last_active was yesterday) + if streak.last_active == activity_date - timedelta(days=1): + streak.current_streak += 1 + elif streak.last_active != activity_date: + # New streak or gap - start at 1 + streak.current_streak = 1 + + streak.last_active = activity_date + streak.longest_streak = max(streak.longest_streak, streak.current_streak) + else: + # No activity - check if streak should reset + if streak.last_active and streak.last_active < activity_date: + streak.current_streak = 0 + + streak.updated_at = datetime.now() + streak.save() + + # Trigger achievement event for streak milestones + if active and streak.current_streak in [7, 30, 60, 100, 365]: + from api.dashboard.achievement.achievement_events import ( + emit_streak_milestone, + ) + + emit_streak_milestone( + user_id=user_id, + streak_type=streak_type, + streak_count=streak.current_streak, + ) + + +@shared_task +def backfill_missing_aggregates(): + """ + Weekly cron: Scan for missing aggregates and backfill. + Run on Sundays at 02:00 UTC. + """ + from db.task import KarmaActivityLog + from db.achievement import UserIgKarma + + # Get all karma logs with IG + karma_logs = ( + KarmaActivityLog.objects.filter(appraiser_approved=True, task__ig__isnull=False) + .values("user_id", "task__ig_id") + .annotate(total_karma=models.Sum("karma"), task_count=models.Count("id")) + ) + + fixed = 0 + for log in karma_logs: + ig_karma, created = UserIgKarma.objects.get_or_create( + user_id=log["user_id"], + ig_id=log["task__ig_id"], + defaults={ + "id": str(uuid.uuid4()), + "total_karma": log["total_karma"], + "task_count": log["task_count"], + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + + if not created: + # Check and fix discrepancies + if ig_karma.total_karma != log["total_karma"]: + ig_karma.total_karma = log["total_karma"] + ig_karma.task_count = log["task_count"] + ig_karma.save() + fixed += 1 + + logger.info(f"Backfill complete. Fixed {fixed} discrepancies.") + return {"fixed": fixed} + + +@shared_task +def integrity_check(): + """ + Daily cron: Check data integrity. + Run at 03:00 UTC daily. + """ + from db.achievement import UserAchievementsLog, AchievementAuditLog + + issues = [] + + # Check for achievements without audit logs + achievements_without_audit = ( + UserAchievementsLog.objects.exclude( + achievement_id__in=AchievementAuditLog.objects.filter( + action="issued" + ).values("achievement_id") + ).count() + ) + + if achievements_without_audit > 0: + issues.append( + f"Found {achievements_without_audit} achievements without audit logs" + ) + + # Check for duplicate user achievements (should not exist with unique constraint) + from django.db.models import Count + + duplicates = ( + UserAchievementsLog.objects.values("user_id", "achievement_id") + .annotate(count=Count("id")) + .filter(count__gt=1) + ) + + if duplicates.exists(): + issues.append(f"Found {duplicates.count()} duplicate achievements") + + if issues: + logger.warning(f"Integrity issues found: {issues}") + else: + logger.info("Integrity check passed") + + return {"issues": issues} + + +# ============================================================================ +# Celery Beat Schedule Configuration +# Add this to your Celery app configuration in settings.py or celery.py +# ============================================================================ +""" +from celery.schedules import crontab + +CELERY_BEAT_SCHEDULE = { + 'evaluate-daily-streaks': { + 'task': 'mu_celery.achievement_cron.evaluate_daily_streaks', + 'schedule': crontab(hour=0, minute=5), # 00:05 UTC daily + }, + 'backfill-aggregates': { + 'task': 'mu_celery.achievement_cron.backfill_missing_aggregates', + 'schedule': crontab(hour=2, minute=0, day_of_week=0), # Sundays 02:00 UTC + }, + 'integrity-check': { + 'task': 'mu_celery.achievement_cron.integrity_check', + 'schedule': crontab(hour=3, minute=0), # 03:00 UTC daily + }, +} +""" diff --git a/mu_celery/achievement_tasks.py b/mu_celery/achievement_tasks.py new file mode 100644 index 000000000..89e50ec85 --- /dev/null +++ b/mu_celery/achievement_tasks.py @@ -0,0 +1,481 @@ +""" +Achievement Celery Tasks + +Handles: +1. Async event processing → Updates aggregates (NOT auto-issue) +2. Claim processing → Idempotent issuance + VC generation + +All operations are idempotent. +""" +import uuid +from datetime import datetime, date +from celery import shared_task +from django.db import transaction, IntegrityError +from django.conf import settings +import requests +import logging + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60) +def process_achievement_event(self, event_id: str): + """ + Process an achievement event - UPDATE AGGREGATES ONLY. + + This does NOT auto-issue achievements. Users must claim eligible achievements. + + 1. Load event + 2. Update aggregates (IG karma, skill progress, daily activity) + 3. Mark event as processed + """ + from db.achievement import AchievementEvent + + try: + event = AchievementEvent.objects.get(id=event_id) + + if event.processed: + return {"status": "already_processed", "event_id": event_id} + + # Update aggregates based on event type + _update_aggregates(event) + + # Mark event as processed + event.processed = True + event.save() + + logger.info(f"Event processed: {event.event_type} for user {event.user_id}") + + return { + "status": "success", + "event_id": event_id, + "message": "Aggregates updated. User can now claim eligible achievements.", + } + + except AchievementEvent.DoesNotExist: + logger.error(f"Event not found: {event_id}") + return {"status": "error", "message": "Event not found"} + except Exception as e: + logger.exception(f"Error processing event {event_id}: {e}") + raise self.retry(exc=e) + + +def _update_aggregates(event): + """Update pre-aggregated tables based on event""" + from db.achievement import UserIgKarma, UserSkillProgress, UserDailyActivity + + user_id = str(event.user_id) + metadata = event.metadata + today = date.today() + + with transaction.atomic(): + # Update daily activity + daily, created = UserDailyActivity.objects.get_or_create( + user_id=user_id, + activity_date=today, + defaults={ + "id": str(uuid.uuid4()), + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + + if event.event_type == "task.completed": + daily.has_task = True + daily.task_count += 1 + daily.karma_earned += metadata.get("karma", 0) + + # Update IG karma if applicable + ig_id = metadata.get("ig_id") + if ig_id: + ig_karma, _ = UserIgKarma.objects.get_or_create( + user_id=user_id, + ig_id=ig_id, + defaults={ + "id": str(uuid.uuid4()), + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + ig_karma.total_karma += metadata.get("karma", 0) + ig_karma.task_count += 1 + ig_karma.last_activity = datetime.now() + ig_karma.save() + + # Update skill progress + for skill_id in metadata.get("skill_ids", []): + progress, _ = UserSkillProgress.objects.get_or_create( + user_id=user_id, + skill_id=skill_id, + defaults={ + "id": str(uuid.uuid4()), + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + progress.completed_task_count += 1 + progress.total_karma += metadata.get("karma", 0) + progress.last_task_at = datetime.now() + progress.save() + + elif event.event_type == "karma.awarded": + daily.has_karma = True + daily.karma_earned += metadata.get("karma", 0) + + ig_id = metadata.get("ig_id") + if ig_id: + ig_karma, _ = UserIgKarma.objects.get_or_create( + user_id=user_id, + ig_id=ig_id, + defaults={ + "id": str(uuid.uuid4()), + "created_at": datetime.now(), + "updated_at": datetime.now(), + }, + ) + ig_karma.total_karma += metadata.get("karma", 0) + ig_karma.last_activity = datetime.now() + ig_karma.save() + + elif event.event_type == "user.login": + daily.has_login = True + + daily.updated_at = datetime.now() + daily.save() + + +# ============================================================================ +# Achievement Claiming +# ============================================================================ + + +def claim_achievement(user_id: str, achievement_id: str) -> dict: + """ + Called when user claims an achievement. + + 1. Verify eligibility via rule engine + 2. Issue achievement (idempotent) + 3. Queue VC generation if applicable + + Returns: {success: bool, message: str, vc_pending: bool} + """ + from api.dashboard.achievement.rule_engine import RuleEvaluator + from db.achievement import Achievement, AchievementRule, UserAchievementsLog + + # Check if already claimed + existing = UserAchievementsLog.objects.filter( + user_id=user_id, achievement_id=achievement_id + ).first() + + if existing: + return {"success": False, "message": "Achievement already claimed"} + + # Get active rule for this achievement + rule = ( + AchievementRule.objects.filter(achievement_id=achievement_id, is_active=True) + .order_by("-version") + .first() + ) + + if not rule: + return {"success": False, "message": "No active rule for this achievement"} + + # Evaluate eligibility + evaluator = RuleEvaluator(user_id) + result = evaluator.evaluate_rule(rule) + + if not result.eligible: + return { + "success": False, + "message": f"Not eligible: {result.reason}", + "progress": result.progress, + } + + # Issue the achievement + success = _issue_achievement( + user_id=user_id, + achievement_id=achievement_id, + rule_version=result.rule_version, + source="user_claim", + ) + + if success: + achievement = Achievement.objects.get(id=achievement_id) + return { + "success": True, + "message": "Achievement claimed successfully!", + "vc_pending": achievement.has_vc, + "achievement_name": achievement.name, + } + else: + return {"success": False, "message": "Failed to claim achievement"} + + +def _issue_achievement( + user_id: str, + achievement_id: str, + rule_version: int, + source: str = "user_claim", + performed_by: str = None, +) -> bool: + """ + Issue achievement with idempotency guarantee. + Called from claim_achievement (user action) or manual_issue (admin). + Returns True if newly issued, False if already exists. + """ + from db.achievement import UserAchievementsLog, Achievement, AchievementAuditLog + + try: + with transaction.atomic(): + # Check if already issued (using unique constraint) + existing = UserAchievementsLog.objects.filter( + user_id=user_id, achievement_id=achievement_id + ).first() + + if existing: + return False # Already issued - idempotent no-op + + # Create achievement record + achievement_log = UserAchievementsLog.objects.create( + id=str(uuid.uuid4()), + user_id_id=user_id, + achievement_id_id=achievement_id, + rule_version=rule_version, + is_issued=True, + vc_url="", + created_by_id=performed_by or user_id, + updated_by_id=performed_by or user_id, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + # Log audit + AchievementAuditLog.objects.create( + id=str(uuid.uuid4()), + user_id=user_id, + achievement_id=achievement_id, + action="issued", + rule_version=rule_version, + metadata={"source": source}, + performed_by_id=performed_by, + created_at=datetime.now(), + ) + + # NOTE: VC issuance is NOT automatic. Users must claim VCs themselves + # from their profile dashboard by clicking "Issue VC". + # The frontend calls Qseverse directly and then updates vc_url via API. + + logger.info(f"Achievement issued: {achievement_id} to user {user_id}") + return True + + except IntegrityError: + # Unique constraint violation - already issued + return False + + +@shared_task(bind=True, max_retries=5, default_retry_delay=300) +def issue_vc_async(self, user_id: str, achievement_id: str, log_id: str): + """ + Issue Verifiable Credential via qseverse. + Retries on failure, but achievement is already issued. + """ + from db.achievement import UserAchievementsLog, Achievement, AchievementAuditLog + from db.user import User + + try: + user = User.objects.get(id=user_id) + achievement = Achievement.objects.get(id=achievement_id) + + payload = { + "api_key": settings.QSEVERSE_API_KEY, + "subject_info": { + "name": user.full_name, + "email": user.email, + "phone": user.mobile or "", + }, + "credential_info": { + "name": achievement.name, + "description": achievement.description, + }, + "template_id": achievement.template_id, + "send_email": True, + } + + response = requests.post( + f"{settings.QSEVERSE_BASE_URL}api/issue_vc_app", + json=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"ApiKey {settings.QSEVERSE_API_KEY}", + }, + timeout=30, + ) + response.raise_for_status() + + vc_data = response.json() + vc_url = vc_data.get("vc_url", "") + + # Update achievement log with VC URL + UserAchievementsLog.objects.filter(id=log_id).update(vc_url=vc_url) + + # Audit log + AchievementAuditLog.objects.create( + id=str(uuid.uuid4()), + user_id=user_id, + achievement_id=achievement_id, + action="vc_issued", + metadata={"vc_url": vc_url}, + created_at=datetime.now(), + ) + + logger.info(f"VC issued for achievement {achievement_id} user {user_id}") + return {"status": "success", "vc_url": vc_url} + + except requests.RequestException as e: + logger.error(f"VC issuance failed for {user_id}/{achievement_id}: {e}") + + # Log failure + AchievementAuditLog.objects.create( + id=str(uuid.uuid4()), + user_id=user_id, + achievement_id=achievement_id, + action="vc_failed", + metadata={"error": str(e), "retry": self.request.retries}, + created_at=datetime.now(), + ) + + raise self.retry(exc=e) + + +# ============================================================================ +# Admin Operations +# ============================================================================ + + +def manual_issue_achievement( + user_id: str, + achievement_id: str, + performed_by: str, +) -> dict: + """ + Manually issue an achievement (admin operation). + Bypasses rule evaluation. + """ + from db.achievement import Achievement + + success = _issue_achievement( + user_id=user_id, + achievement_id=achievement_id, + rule_version=0, # Manual issue has no rule version + source="admin_manual", + performed_by=performed_by, + ) + + if success: + achievement = Achievement.objects.get(id=achievement_id) + return { + "success": True, + "message": f"Achievement '{achievement.name}' issued to user", + "vc_pending": achievement.has_vc, + } + else: + return {"success": False, "message": "Achievement already issued or failed"} + + +def revoke_achievement( + user_id: str, + achievement_id: str, + performed_by: str, + reason: str = None, +) -> dict: + """ + Revoke an achievement (admin operation). + """ + from db.achievement import UserAchievementsLog, AchievementAuditLog + + achievement_log = UserAchievementsLog.objects.filter( + user_id=user_id, achievement_id=achievement_id + ).first() + + if not achievement_log: + return {"success": False, "message": "Achievement not found for user"} + + # Delete the achievement log + achievement_log.delete() + + # Audit log + AchievementAuditLog.objects.create( + id=str(uuid.uuid4()), + user_id=user_id, + achievement_id=achievement_id, + action="revoked", + metadata={"reason": reason}, + performed_by_id=performed_by, + created_at=datetime.now(), + ) + + logger.info(f"Achievement {achievement_id} revoked from user {user_id}") + return {"success": True, "message": "Achievement revoked successfully"} + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60) +def bulk_check_and_issue_achievements( + self, date_from_str: str, date_to_str: str, performed_by_id: str = None +): + """ + Check and issue all eligible achievements for users active within the given date range. + """ + from db.task import KarmaActivityLog + from api.dashboard.achievement.rule_engine import RuleEvaluator + + try: + date_from = date.fromisoformat(date_from_str) + date_to = date.fromisoformat(date_to_str) + + # 1. Get unique users active in the range + user_ids = list( + KarmaActivityLog.objects.filter( + updated_at__date__range=[date_from, date_to], + appraiser_approved=True, + ) + .values_list("user_id", flat=True) + .distinct() + ) + + logger.info( + f"Bulk sync: Processing {len(user_ids)} users active between {date_from} and {date_to}" + ) + + issued_count = 0 + + for user_id in user_ids: + try: + evaluator = RuleEvaluator(str(user_id)) + eligible_results = evaluator.get_eligible_achievements() + + for result in eligible_results: + if result.eligible: + success = _issue_achievement( + user_id=str(user_id), + achievement_id=result.achievement_id, + rule_version=result.rule_version, + source="bulk_sync", + performed_by=performed_by_id, + ) + if success: + issued_count += 1 + except Exception as e: + logger.error(f"Error processing user {user_id} in bulk sync: {e}") + + logger.info( + f"Bulk sync complete. Users: {len(user_ids)}, Issued: {issued_count}" + ) + return { + "status": "success", + "users_processed": len(user_ids), + "achievements_issued": issued_count, + } + + except Exception as e: + logger.exception(f"Bulk sync failed: {e}") + raise self.retry(exc=e) diff --git a/mu_celery/intern_cron.py b/mu_celery/intern_cron.py new file mode 100644 index 000000000..673ad0298 --- /dev/null +++ b/mu_celery/intern_cron.py @@ -0,0 +1,155 @@ +from celery import shared_task +from django.utils.timezone import now +from datetime import timedelta +from db.intern import UserInternGuildLink, InternDailyTimesheet, InternTask +from utils.types import InternGuildStatus, InternTaskStatus +from db.intern import UserInternGuildLink, InternDailyTimesheet, InternLeaveRequest +from utils.types import InternGuildStatus, InternLeaveStatus + +@shared_task +def intern_daily_status_cron(): + """ + Cron job to evaluate intern status. + Rules: + - >= 6 missed timesheets (out of last 10 working days) -> AT_RISK + - >= 10 missed timesheets (out of last 10 working days) -> INACTIVE + """ + active_interns = UserInternGuildLink.objects.filter( + status__in=[InternGuildStatus.ACTIVE.value, InternGuildStatus.AT_RISK.value] + ) + + today = now().date() + + # 1. Batch fetch all timesheets for the last 20 days (to cover 10 working days) + date_range_start = today - timedelta(days=20) + active_intern_user_ids = list(active_interns.values_list('user_id', flat=True)) + + # Using set of tuples for O(1) lookup + all_timesheets = set( + InternDailyTimesheet.objects.filter( + user_id__in=active_intern_user_ids, + entry_date__gte=date_range_start, + entry_date__lte=today + ).values_list('user_id', 'entry_date') + ) + + # 2. Batch fetch all approved leaves for the last 10 days + approved_leaves_qs = InternLeaveRequest.objects.filter( + user_id__in=active_intern_user_ids, + status=InternLeaveStatus.APPROVED.value, + end_date__gte=date_range_start, + start_date__lte=today + ).values('user_id', 'start_date', 'end_date') + + # Map user_id to list of approved leaves + approved_leaves_by_user = {} + for leave in approved_leaves_qs: + user_id = leave['user_id'] + if user_id not in approved_leaves_by_user: + approved_leaves_by_user[user_id] = [] + approved_leaves_by_user[user_id].append((leave['start_date'], leave['end_date'])) + + def is_on_leave(user_id, check_date): + leaves = approved_leaves_by_user.get(user_id, []) + for start, end in leaves: + if start <= check_date <= end: + return True + return False + + for intern in active_interns: + missed_count = 0 + days_checked = 0 + days_back = 1 + + while days_checked < 10: + check_date = today - timedelta(days=days_back) + days_back += 1 + + # Skip weekends + if check_date.weekday() > 4: + continue + + # Skip approved leave days + if is_on_leave(intern.user_id, check_date): + days_checked += 1 + continue + + has_timesheet = (intern.user_id, check_date) in all_timesheets + + if not has_timesheet: + missed_count += 1 + + days_checked += 1 + + new_status = intern.status + if missed_count >= 10: + new_status = InternGuildStatus.INACTIVE.value + elif missed_count >= 6: + new_status = InternGuildStatus.AT_RISK.value + else: + new_status = InternGuildStatus.ACTIVE.value + + if intern.status != new_status: + intern.status = new_status + # Limitation: No system-user convention exists for updated_by_id in cron jobs. + # Leaving updated_by_id unchanged. + intern.save(update_fields=['status']) + + # 2.5. Handle approved leaves starting today: transition to ON_LEAVE + newly_on_leave = InternLeaveRequest.objects.filter( + status=InternLeaveStatus.APPROVED.value, + start_date__lte=today, + end_date__gte=today + ).values_list('user_id', flat=True) + + for user_id in newly_on_leave: + guild_link = UserInternGuildLink.objects.filter(user_id=user_id).first() + if guild_link and guild_link.status != InternGuildStatus.ON_LEAVE.value: + guild_link.previous_status = guild_link.status + guild_link.status = InternGuildStatus.ON_LEAVE.value + guild_link.save(update_fields=['status', 'previous_status']) + + # 3. Handle ON_LEAVE -> ACTIVE restoration + on_leave_interns = UserInternGuildLink.objects.filter( + status=InternGuildStatus.ON_LEAVE.value + ) + + if on_leave_interns.exists(): + on_leave_intern_user_ids = list(on_leave_interns.values_list('user_id', flat=True)) + # Check active leaves covering today + active_leaves_today = set( + InternLeaveRequest.objects.filter( + user_id__in=on_leave_intern_user_ids, + status=InternLeaveStatus.APPROVED.value, + start_date__lte=today, + end_date__gte=today + ).values_list('user_id', flat=True) + ) + + for intern in on_leave_interns: + if intern.user_id not in active_leaves_today: + restored_status = intern.previous_status or InternGuildStatus.ACTIVE.value + valid_targets = [InternGuildStatus.ACTIVE.value, InternGuildStatus.AT_RISK.value] + if restored_status not in valid_targets: + restored_status = InternGuildStatus.ACTIVE.value + + intern.status = restored_status + intern.previous_status = None + intern.save(update_fields=['status', 'previous_status']) + +@shared_task +def intern_task_deadline_cron(): + """ + Cron job to evaluate intern tasks deadline. + Rules: + - If deadline < today and status != COMPLETED and not verified -> OVERDUE + """ + today = now().date() + overdue_tasks = InternTask.objects.filter( + deadline__lt=today, + is_verified=False + ).exclude(status=InternTaskStatus.COMPLETED.value).exclude(status=InternTaskStatus.OVERDUE.value) + + for task in overdue_tasks: + task.status = InternTaskStatus.OVERDUE.value + task.save() diff --git a/mu_celery/media_content_tasks.py b/mu_celery/media_content_tasks.py new file mode 100644 index 000000000..8fa755756 --- /dev/null +++ b/mu_celery/media_content_tasks.py @@ -0,0 +1,73 @@ +""" +Celery tasks for the media_content module. + +Keeping long-running or external-API-heavy work out of the request path. +""" +import logging + +from celery import shared_task + +from api.dashboard.media_content.image_utils import delete_stale_media, fetch_image_from_url + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=30) +def fetch_and_attach_poster( + self, + record_id: str, + url: str, + subdir: str, + field: str = 'poster_thumbnail', + old_path: str | None = None, +) -> None: + """ + Download a remote poster image and write the result back to a MediaContent record. + + Args: + record_id: PK of the MediaContent row to update. + url: The remote image URL to fetch. + subdir: Storage subdirectory under MEDIA_ROOT/media_content/ (e.g. 'posters'). + field: Model field to update (default: 'poster_thumbnail'). + old_path: Relative path of the previous file to delete *after* a + successful download. Passed by the PATCH view so that + stale-file cleanup only happens once the replacement is + safely written, avoiding unrecoverable data loss on retry + failure. + """ + # Import here to avoid circular imports at module load time. + from db.events import MediaContent + + rel, err = fetch_image_from_url(url, subdir) + if err: + logger.warning( + 'fetch_and_attach_poster: could not download image for record %s ' + '(field=%s): %s', + record_id, field, err, + ) + try: + raise self.retry(exc=RuntimeError(err)) + except self.MaxRetriesExceededError: + logger.error( + 'fetch_and_attach_poster: gave up after %d retries for record %s (field=%s)', + self.max_retries, record_id, field, + ) + return + + updated = MediaContent.objects.filter(id=record_id).update(**{field: rel}) + if not updated: + logger.warning( + 'fetch_and_attach_poster: MediaContent %s not found when trying to set %s', + record_id, field, + ) + return + + # Only delete the old file *after* the DB row is confirmed updated so + # the old asset is never removed before its replacement is in place. + delete_stale_media(old_path, rel) + + logger.info( + 'fetch_and_attach_poster: set %s=%s on MediaContent %s', + field, rel, record_id, + ) + diff --git a/mulearnbackend/__init__.py b/mulearnbackend/__init__.py index 5568b6d79..3086f05c7 100644 --- a/mulearnbackend/__init__.py +++ b/mulearnbackend/__init__.py @@ -2,4 +2,20 @@ # Django starts so that shared_task will use this app. from .celery import app as celery_app +from rest_framework.serializers import Serializer +from rest_framework.exceptions import ValidationError + +original_to_internal_value = Serializer.to_internal_value + +def strict_to_internal_value(self, data): + if isinstance(data, dict): + unknown_keys = set(data.keys()) - set(self.fields.keys()) + if unknown_keys: + errors = {key: ["Unknown field."] for key in unknown_keys} + raise ValidationError(errors) + + return original_to_internal_value(self, data) + +Serializer.to_internal_value = strict_to_internal_value + __all__ = ("celery_app",) diff --git a/mulearnbackend/middlewares.py b/mulearnbackend/middlewares.py index 70297744b..c1ee258a1 100644 --- a/mulearnbackend/middlewares.py +++ b/mulearnbackend/middlewares.py @@ -107,7 +107,7 @@ def log_exception(self, request, exception): """ - body = request._body.decode("utf-8") if hasattr(request, "_body") else "No body" + body = request._body.decode("utf-8", errors="replace") if hasattr(request, "_body") else "No body" auth = request.auth if hasattr(request, "auth") else "No Auth data" with suppress(json.JSONDecodeError): diff --git a/mulearnbackend/settings.py b/mulearnbackend/settings.py index 29fab0bf0..bac809d9e 100644 --- a/mulearnbackend/settings.py +++ b/mulearnbackend/settings.py @@ -53,15 +53,16 @@ "api.apps.ApiConfig", "corsheaders", "db", + "drf_spectacular", ] MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "debug_toolbar.middleware.DebugToolbarMiddleware", "django.middleware.common.CommonMiddleware", - "corsheaders.middleware.CorsMiddleware", "mulearnbackend.middlewares.UniversalErrorHandlerMiddleware", ] @@ -69,8 +70,45 @@ CORS_ALLOW_ALL_ORIGINS = True REST_FRAMEWORK = { - "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",) + "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } + +SPECTACULAR_SETTINGS = { + "TITLE": "muLearn API", + "DESCRIPTION": "API documentation for the muLearn Platform backend.", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_PATCH": True, + "COMPONENT_SPLIT_COMMAND": True, + "ENUM_NAME_POSTFIX": "_Enum", + "SWAGGER_UI_SETTINGS": { + "deepLinking": True, + "persistAuthorization": True, + "displayOperationId": True, + }, + "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"] if not decouple_config("ENABLE_SWAGGER", default=False, cast=bool) else ["rest_framework.permissions.AllowAny"], + "SECURITY": [ + { + "jwtAuth": [], + } + ], + "APPEND_COMPONENTS": { + "securitySchemes": { + "jwtAuth": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": 'JWT Token Authentication. Enter **"Bearer \u003ctoken\u003e"**', + } + } + }, + "SCHEMA_PATH_PREFIX": r"/api/v1/", + "POSTPROCESSING_HOOKS": ["utils.spectacular.custom_postprocessing_hook"], + "DEFAULT_FORMAT": "json", +} + + # paginator settings PAGE_SIZE = 10 TEMPLATES = [ @@ -265,6 +303,8 @@ QSEVERSE_BASE_URL = decouple_config("QSEVERSE_BASE_URL") QSEVERSE_API_KEY = decouple_config("QSEVERSE_API_KEY") +BACKEND_API_KEY = decouple_config("BACKEND_API_KEY") + DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage" import socket @@ -272,3 +312,18 @@ hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) INTERNAL_IPS = [f"{ip[:-1]}1" for ip in ips] + ["127.0.0.1", "10.0.2.2"] DEFAULT_CHARSET = "utf-8" +from celery.schedules import crontab +CELERY_BEAT_SCHEDULE = { + 'intern-daily-status-cron': { + 'task': 'mu_celery.intern_cron.intern_daily_status_cron', + 'schedule': crontab(hour=0, minute=5), + }, + 'intern-task-deadline-cron': { + 'task': 'mu_celery.intern_cron.intern_task_deadline_cron', + 'schedule': crontab(hour=0, minute=10), + }, + 'update-alumni-status-cron': { + 'task': 'mu_celery.alumni_cron.update_alumni_status_cron', + 'schedule': crontab(hour=0, minute=15), + }, +} diff --git a/mulearnbackend/urls.py b/mulearnbackend/urls.py index 1cebec424..468db75e6 100644 --- a/mulearnbackend/urls.py +++ b/mulearnbackend/urls.py @@ -15,15 +15,38 @@ """ from django.conf import settings -# from django.conf.urls.static import static -# from django.contrib import admin from django.urls import path, include, re_path from django.views.static import serve +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) +from decouple import config as decouple_config urlpatterns = [ # path('admin/', admin.site.urls), - path('api/v1/', include('api.urls')), - re_path(r'^muback-media/(?P.*)$',serve,{'document_root':settings.MEDIA_ROOT}) + path("api/v1/", include("api.urls")), + re_path( + r"^muback-media/(?P.*)$", serve, {"document_root": settings.MEDIA_ROOT} + ), + # OpenAPI Schema + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), ] +if decouple_config("ENABLE_SWAGGER", default=False, cast=bool): + urlpatterns += [ + path( + "api/docs/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), + path( + "api/redoc/", + SpectacularRedocView.as_view(url_name="schema"), + name="redoc", + ), + ] + + # urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 000000000..66ae71436 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,39073 @@ +openapi: 3.0.3 +info: + title: muLearn API + version: 1.0.0 + description: API documentation for the muLearn Platform backend. +paths: + /api/v1/auth/apple-mobile/: + post: + operationId: auth_apple_mobile_create + description: Create Apple Mobile Auth Proxy. + tags: + - Auth + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AuthAppleTokenResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/auth/google-mobile/: + post: + operationId: auth_google_mobile_create + description: Create Google Mobile Auth Proxy. + tags: + - Auth + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AuthTokenResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/auth/refresh-token/: + post: + operationId: auth_refresh_token_create + description: Create Refresh Token Proxy. + tags: + - Auth + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AuthRefreshTokenResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/auth/user-authentication/: + post: + operationId: auth_user_authentication_create + description: Create User Authentication Proxy. + tags: + - Auth + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AuthUserAuthResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/calendar/campus-mentor/{campus_id}/sessions/: + get: + operationId: calendar_campus_mentor_sessions_list + description: 'Calendar view of mentorship sessions for a specific campus. Returns + sessions grouped as upcoming, ongoing, and completed. Filter by month using + the `month` query parameter (format: YYYY-MM).' + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + - in: query + name: month + schema: + type: string + description: Filter by month (YYYY-MM) + - in: query + name: status + schema: + type: string + description: 'Filter by status: SCHEDULED, COMPLETED, CANCELLED' + tags: + - Calendar + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/MentorshipSessionCalendar' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/calendar/campus/{campus_id}/events/: + get: + operationId: calendar_campus_events_list + description: Events calendar scoped to a specific Campus. Returns events grouped + as upcoming, ongoing, and completed. Supports optional month filtering. + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + - in: query + name: month + schema: + type: string + description: Filter by month (YYYY-MM) + - in: query + name: status + schema: + type: string + description: 'Filter by status: ongoing, upcoming, completed' + tags: + - Calendar + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/EventCalendarItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/calendar/company/{company_id}/events/: + get: + operationId: calendar_company_events_list + description: Events calendar scoped to a specific Company. Returns events grouped + as upcoming, ongoing, and completed. Supports optional month filtering. + parameters: + - in: path + name: company_id + schema: + type: string + required: true + - in: query + name: month + schema: + type: string + description: Filter by month (YYYY-MM) + - in: query + name: status + schema: + type: string + description: 'Filter by status: ongoing, upcoming, completed' + tags: + - Calendar + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/EventCalendarItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/calendar/company/{company_org_id}/sessions/: + get: + operationId: calendar_company_sessions_list + description: 'Calendar view of mentorship sessions for a specific company org. + Returns sessions grouped as upcoming, ongoing, and completed. Filter by month + using the `month` query parameter (format: YYYY-MM).' + parameters: + - in: path + name: company_org_id + schema: + type: string + required: true + - in: query + name: month + schema: + type: string + description: Filter by month (YYYY-MM) + - in: query + name: status + schema: + type: string + description: 'Filter by status: SCHEDULED, COMPLETED, CANCELLED' + tags: + - Calendar + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/MentorshipSessionCalendar' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/calendar/events/: + get: + operationId: calendar_events_list + description: Global platform events calendar. Returns events grouped as upcoming, + ongoing, and completed. Supports optional filtering by month (YYYY-MM) and + scope. + parameters: + - in: query + name: month + schema: + type: string + description: Filter by month (YYYY-MM) + - in: query + name: scope + schema: + type: string + description: 'Filter by scope: ig, campus, global, company' + - in: query + name: status + schema: + type: string + description: 'Filter by status: ongoing, upcoming, completed' + tags: + - Calendar + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/EventCalendarItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/calendar/ig-mentor/{ig_id}/sessions/: + get: + operationId: calendar_ig_mentor_sessions_list + description: 'Calendar view of mentorship sessions for a specific Interest Group. + Returns sessions grouped as upcoming, ongoing, and completed. Filter by month + using the `month` query parameter (format: YYYY-MM).' + parameters: + - in: path + name: ig_id + schema: + type: string + required: true + - in: query + name: month + schema: + type: string + description: Filter by month (YYYY-MM) + - in: query + name: status + schema: + type: string + description: 'Filter by status: SCHEDULED, COMPLETED, CANCELLED' + tags: + - Calendar + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/MentorshipSessionCalendar' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/calendar/ig/{ig_id}/events/: + get: + operationId: calendar_ig_events_list + description: Events calendar scoped to a specific Interest Group. Returns events + grouped as upcoming, ongoing, and completed. Supports optional month filtering. + parameters: + - in: path + name: ig_id + schema: + type: string + required: true + - in: query + name: month + schema: + type: string + description: Filter by month (YYYY-MM) + - in: query + name: status + schema: + type: string + description: 'Filter by status: ongoing, upcoming, completed' + tags: + - Calendar + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/EventCalendarItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/audit/{muid}/: + get: + operationId: dashboard_achievement_audit_retrieve + description: Retrieve Audit Log. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AchievementAuditLogResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/bulk-claim/: + post: + operationId: dashboard_achievement_bulk_claim_create + description: Create Bulk Claim Task Achievement. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/bulk-issue/: + post: + operationId: dashboard_achievement_bulk_issue_create + description: Create Achievement Issue Bulk. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/bulk-issue/template/: + get: + operationId: dashboard_achievement_bulk_issue_template_retrieve + description: Retrieve Achievement Bulk Import Template. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/claim/{achievement_id}/: + post: + operationId: dashboard_achievement_claim_create + description: Create Claim Achievement. + parameters: + - in: path + name: achievement_id + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/create/: + post: + operationId: dashboard_achievement_create_create + description: Create Achievement Create. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/debug/{muid}/{achievement_id}/: + get: + operationId: dashboard_achievement_debug_retrieve + description: Retrieve Debug Achievement. + parameters: + - in: path + name: achievement_id + schema: + type: string + required: true + - in: path + name: muid + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/delete/{achievement_id}/: + delete: + operationId: dashboard_achievement_delete_destroy + description: Delete Achievement Delete. + parameters: + - in: path + name: achievement_id + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/eligible/: + get: + operationId: dashboard_achievement_eligible_retrieve + description: Retrieve Eligible Achievements. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/issue-vc/: + post: + operationId: dashboard_achievement_issue_vc_create + description: Create User Achievements Issue. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/issued-log/: + get: + operationId: dashboard_achievement_issued_log_retrieve + description: Retrieve Achievement Log List. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/list/: + get: + operationId: dashboard_achievement_list_retrieve + description: Retrieve Achievement List. Pass ?user_id= to include a 'has_achievement' + flag per item. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/list/user/{muid}/: + get: + operationId: dashboard_achievement_list_user_retrieve + description: Retrieve User Achievements List. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserAchievements' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/manual-issue/: + post: + operationId: dashboard_achievement_manual_issue_create + description: Create Manual Issue. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: Achievement manually issued to user + /api/v1/dashboard/achievement/progress/: + get: + operationId: dashboard_achievement_progress_retrieve + description: Retrieve User Progress. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AchievementUserProgressResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/revoke/: + post: + operationId: dashboard_achievement_revoke_create + description: Create Revoke Achievement. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/rules/: + get: + operationId: dashboard_achievement_rules_retrieve + description: Retrieve Achievement Rule List. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/rules/{rule_id}/: + get: + operationId: dashboard_achievement_rules_retrieve_2 + description: Retrieve Achievement Rule Detail. + parameters: + - in: path + name: rule_id + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_achievement_rules_partial_update + description: Update a rule's rule_type and/or conditions. Works for both active + and deactivated rules. Version and achievement association are immutable. + parameters: + - in: path + name: rule_id + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/rules/{rule_id}/activate/: + post: + operationId: dashboard_achievement_rules_activate_create + description: Activate an Achievement Rule. + parameters: + - in: path + name: rule_id + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/rules/{rule_id}/deactivate/: + post: + operationId: dashboard_achievement_rules_deactivate_create + description: Create Achievement Rule Deactivate. + parameters: + - in: path + name: rule_id + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/rules/create/: + post: + operationId: dashboard_achievement_rules_create_create + description: Create Achievement Rule Create. + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/simulate/{muid}/: + get: + operationId: dashboard_achievement_simulate_retrieve + description: Retrieve Simulate Rules. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AchievementSimulateRulesResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/achievement/update/{achievement_id}/: + put: + operationId: dashboard_achievement_update_update + description: Update Achievement Update. + parameters: + - in: path + name: achievement_id + schema: + type: string + required: true + tags: + - Achievement + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Achievement' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/affiliation/: + get: + operationId: dashboard_affiliation_retrieve + description: Retrieve Affiliation C R U D. + tags: + - Affiliation + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AffiliationList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_affiliation_create + tags: + - Affiliation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_affiliation_update + tags: + - Affiliation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_affiliation_destroy + tags: + - Affiliation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/affiliation/{affiliation_id}/: + get: + operationId: dashboard_affiliation_retrieve_2 + description: Retrieve Affiliation C R U D. + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Affiliation + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AffiliationList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_affiliation_create_2 + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Affiliation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_affiliation_update_2 + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Affiliation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_affiliation_destroy_2 + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Affiliation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/calendar/events/: + get: + operationId: dashboard_calendar_events_list + description: Unified dashboard calendar. Returns events and mentor sessions + based on the caller's role (detected from JWT token). Supports date-range + filtering via start_date and end_date. + parameters: + - in: query + name: end_date + schema: + type: string + format: date + description: End of date range (YYYY-MM-DD). Required if month is not provided. + - in: query + name: month + schema: + type: string + description: Optional. Filter by month name. Mutually exclusive with start_date/end_date. + - in: query + name: start_date + schema: + type: string + format: date + description: Start of date range (YYYY-MM-DD). Required if month is not provided. + - in: query + name: status + schema: + type: string + description: 'Optional. Filter by status: upcoming, ongoing, completed.' + - in: query + name: year + schema: + type: integer + description: Optional. Used with month filter (defaults to current year). + tags: + - Calendar + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/EventCalendarItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/{org_id}/: + get: + operationId: dashboard_campus_retrieve + description: Retrieve Campus Details Public. + parameters: + - in: path + name: org_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusDetailsPublic' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/{org_id}/karma-by-cluster/: + get: + operationId: dashboard_campus_karma_by_cluster_retrieve + description: Retrieve Campus Karma By Cluster. + parameters: + - in: path + name: org_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusKarmaByClusterResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/{org_id}/leaderboard/: + get: + operationId: dashboard_campus_leaderboard_retrieve + description: Retrieve Campus Student Leaderboard. + parameters: + - in: path + name: org_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusLeaderboard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/analytics/growth/: + get: + operationId: dashboard_campus_analytics_growth_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/analytics/karma-trend/: + get: + operationId: dashboard_campus_analytics_karma_trend_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/assign-mentor/: + post: + operationId: dashboard_campus_assign_mentor_create + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/campus-details/: + get: + operationId: dashboard_campus_campus_details_retrieve + description: |- + Campus Details API + + This API view allows authorized users with specific roles (Campus Lead or Enabler) + to access details about their campus + + Attributes: + authentication_classes (list): A list containing the CustomizePermission class for authentication. + + Method: + get(request): Handles GET requests to retrieve campus details for the authenticated user. + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/campus-list/: + get: + operationId: dashboard_campus_campus_list_retrieve + description: Retrieve Campus List. + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/change-student-type/{member_id}/: + patch: + operationId: dashboard_campus_change_student_type_partial_update + parameters: + - in: path + name: member_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/circle-health/: + get: + operationId: dashboard_campus_circle_health_retrieve + description: Retrieve Campus Circle Health. + tags: + - Campus + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusCircleHealthResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/events/: + get: + operationId: dashboard_campus_events_retrieve + description: |- + GET campus/events/ + Returns paginated campus-scoped and campus-IG-scoped events + for the authenticated campus lead's campus. + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/events/distribution/: + get: + operationId: dashboard_campus_events_distribution_retrieve + description: |- + GET campus/events/distribution/ + Returns ranked tag distribution for all campus events. + Aggregates from Event.tags JSONField using Counter. + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/execom/: + get: + operationId: dashboard_campus_execom_retrieve + description: |- + GET campus/execom/ — list all execom role holders + POST campus/execom/ — appoint a member to a role + DELETE campus/execom// — remove a role link + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_campus_execom_create + description: |- + GET campus/execom/ — list all execom role holders + POST campus/execom/ — appoint a member to a role + DELETE campus/execom// — remove a role link + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_campus_execom_destroy + description: |- + GET campus/execom/ — list all execom role holders + POST campus/execom/ — appoint a member to a role + DELETE campus/execom// — remove a role link + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/campus/execom/{member_id}/: + get: + operationId: dashboard_campus_execom_retrieve_2 + description: |- + GET campus/execom/ — list all execom role holders + POST campus/execom/ — appoint a member to a role + DELETE campus/execom// — remove a role link + parameters: + - in: path + name: member_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_campus_execom_create_2 + description: |- + GET campus/execom/ — list all execom role holders + POST campus/execom/ — appoint a member to a role + DELETE campus/execom// — remove a role link + parameters: + - in: path + name: member_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_campus_execom_destroy_2 + description: |- + GET campus/execom/ — list all execom role holders + POST campus/execom/ — appoint a member to a role + DELETE campus/execom// — remove a role link + parameters: + - in: path + name: member_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/campus/execom/roles/: + get: + operationId: dashboard_campus_execom_roles_retrieve + description: |- + GET campus/execom/roles/ — list all assignable roles + POST campus/execom/roles/ — explicitly create a custom role + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_campus_execom_roles_create + description: |- + GET campus/execom/roles/ — list all assignable roles + POST campus/execom/roles/ — explicitly create a custom role + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/execom/search/: + get: + operationId: dashboard_campus_execom_search_retrieve + description: |- + GET campus/execom/search/?q= + + Searches campus members by full_name or muid (case-insensitive partial match). + Returns all matching members regardless of whether they already hold an Execom + role — this fixes the inconsistency where some existing Execom members were + missing from search results because a plain exact-muid lookup was used. + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/home-summary/: + get: + operationId: dashboard_campus_home_summary_retrieve + description: Retrieve Campus Dashboard Summary. + tags: + - Campus + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusDashboardSummaryResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/ig-chapters/: + get: + operationId: dashboard_campus_ig_chapters_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_campus_ig_chapters_create + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_campus_ig_chapters_partial_update + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_campus_ig_chapters_destroy + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/campus/ig-chapters/{chapter_id}/: + get: + operationId: dashboard_campus_ig_chapters_retrieve_2 + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_campus_ig_chapters_create_2 + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_campus_ig_chapters_partial_update_2 + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_campus_ig_chapters_destroy_2 + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/campus/ig-chapters/{chapter_id}/join/: + post: + operationId: dashboard_campus_ig_chapters_join_create + description: Join a Campus IG Chapter. The authenticated user must be a verified + member of the same campus organization that hosts this chapter. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/ig-chapters/{chapter_id}/leave/: + delete: + operationId: dashboard_campus_ig_chapters_leave_destroy + description: Leave a Campus IG Chapter. The authenticated user's active learner + link to the Interest Group is deactivated. History is preserved. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/igs/: + get: + operationId: dashboard_campus_igs_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/igs/{ig_id}/members/: + get: + operationId: dashboard_campus_igs_members_retrieve + parameters: + - in: path + name: ig_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/learning-circles/: + get: + operationId: dashboard_campus_learning_circles_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/learning-circles/{circle_id}/members/: + get: + operationId: dashboard_campus_learning_circles_members_retrieve + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/member-funnel/: + get: + operationId: dashboard_campus_member_funnel_retrieve + description: Retrieve Campus Member Funnel. + tags: + - Campus + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusMemberFunnelResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/recent-activity/: + get: + operationId: dashboard_campus_recent_activity_retrieve + description: Retrieve Campus Recent Activity. + tags: + - Campus + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusRecentActivityResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/sessions/list/: + get: + operationId: dashboard_campus_sessions_list_list + description: List campus mentorship sessions. Admins, campus leads, enablers, + and approved campus mentors see all statuses; other users see only scheduled + sessions. + parameters: + - in: query + name: status + schema: + type: string + tags: + - Campus + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/SessionList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/showcase/: + get: + operationId: dashboard_campus_showcase_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_campus_showcase_partial_update + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/social-links/: + put: + operationId: dashboard_campus_social_links_update + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_campus_social_links_destroy + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/campus/social-links/{link_id}/: + put: + operationId: dashboard_campus_social_links_update_2 + parameters: + - in: path + name: link_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_campus_social_links_destroy_2 + parameters: + - in: path + name: link_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/campus/student-details/: + get: + operationId: dashboard_campus_student_details_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/student-details/csv/: + get: + operationId: dashboard_campus_student_details_csv_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/student-level/: + get: + operationId: dashboard_campus_student_level_retrieve + description: Retrieve Campus Student In Each Level. + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusStudentInEachLevelResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/student-level/{org_id}/: + get: + operationId: dashboard_campus_student_level_retrieve_2 + description: Retrieve Campus Student In Each Level. + parameters: + - in: path + name: org_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CampusStudentInEachLevelResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/student-list/: + get: + operationId: dashboard_campus_student_list_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/students/{muid}/activity/: + get: + operationId: dashboard_campus_students_activity_retrieve + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/transfer-enabler-role/: + post: + operationId: dashboard_campus_transfer_enabler_role_create + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/transfer-ig-role/: + get: + operationId: dashboard_campus_transfer_ig_role_retrieve + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_campus_transfer_ig_role_create + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/transfer-lead-role/: + post: + operationId: dashboard_campus_transfer_lead_role_create + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/campus/weekly-karma/: + get: + operationId: dashboard_campus_weekly_karma_retrieve + description: Retrieve Weekly Karma. + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WeeklyKarma' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/campus/weekly-karma/{org_id}/: + get: + operationId: dashboard_campus_weekly_karma_retrieve_2 + description: Retrieve Weekly Karma. + parameters: + - in: path + name: org_id + schema: + type: string + required: true + tags: + - Campus + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WeeklyKarma' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/category/: + get: + operationId: dashboard_category_retrieve + description: Retrieve Category. + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CategoryList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_category_create + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_category_update + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_category_partial_update + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_category_destroy + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/category/{category_id}/: + get: + operationId: dashboard_category_retrieve_2 + description: Retrieve Category. + parameters: + - in: path + name: category_id + schema: + type: string + required: true + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CategoryList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_category_create_2 + parameters: + - in: path + name: category_id + schema: + type: string + required: true + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_category_update_2 + parameters: + - in: path + name: category_id + schema: + type: string + required: true + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_category_partial_update_2 + parameters: + - in: path + name: category_id + schema: + type: string + required: true + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_category_destroy_2 + parameters: + - in: path + name: category_id + schema: + type: string + required: true + tags: + - Category + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/channels/: + get: + operationId: dashboard_channels_retrieve + description: Retrieve Channel C R U D. + tags: + - Channels + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChannelList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_channels_create + tags: + - Channels + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_channels_update + tags: + - Channels + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_channels_destroy + tags: + - Channels + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/channels/{channel_id}/: + get: + operationId: dashboard_channels_retrieve_2 + description: Retrieve Channel C R U D. + parameters: + - in: path + name: channel_id + schema: + type: string + required: true + tags: + - Channels + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChannelList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_channels_create_2 + parameters: + - in: path + name: channel_id + schema: + type: string + required: true + tags: + - Channels + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_channels_update_2 + parameters: + - in: path + name: channel_id + schema: + type: string + required: true + tags: + - Channels + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_channels_destroy_2 + parameters: + - in: path + name: channel_id + schema: + type: string + required: true + tags: + - Channels + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/college/: + get: + operationId: dashboard_college_retrieve + description: Retrieve College Api. + tags: + - College + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CollegeList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/college/{college_code}/: + get: + operationId: dashboard_college_retrieve_2 + description: Retrieve College Api. + parameters: + - in: path + name: college_code + schema: + type: string + required: true + tags: + - College + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CollegeList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/college/change-college/: + patch: + operationId: dashboard_college_change_college_partial_update + description: Partially update College Change. + tags: + - College + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedCollegeChange' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedCollegeChange' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedCollegeChange' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CollegeChange' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/{company_id}/: + get: + operationId: dashboard_company_retrieve + description: Get details of a specific company by ID. + parameters: + - in: path + name: company_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/analytics/gigs/: + get: + operationId: dashboard_company_analytics_gigs_retrieve + description: Retrieve analytics data for company gigs (creator or approved company + mentor). + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyGigAnalyticsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/applications/{app_id}/resubmit/: + patch: + operationId: dashboard_company_applications_resubmit_partial_update + description: Resubmit a rejected job application. + parameters: + - in: path + name: app_id + schema: + type: string + required: true + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedUserApplicationResubmit' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedUserApplicationResubmit' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedUserApplicationResubmit' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/applications/{app_id}/status/: + patch: + operationId: dashboard_company_applications_status_partial_update + description: Update the status of a job application (creator or company mentor). + parameters: + - in: path + name: app_id + schema: + type: string + required: true + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedApplicationTracking' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedApplicationTracking' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedApplicationTracking' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ApplicationTracking' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/applications/{app_id}/withdraw/: + delete: + operationId: dashboard_company_applications_withdraw_destroy + description: Withdraw a submitted job application. + parameters: + - in: path + name: app_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/company/applications/me/: + get: + operationId: dashboard_company_applications_me_list + description: List all jobs the user has applied to. + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/UserAppliedJobs' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/home-summary/: + get: + operationId: dashboard_company_home_summary_retrieve + description: Retrieve dashboard summary statistics for the active company. + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/jobs/: + get: + operationId: dashboard_company_jobs_list + description: List all jobs for the authenticated company (creator or company + mentor). + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/JobList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_company_jobs_create + description: Post a new job/gig. Jobs are always created with status='Draft' + regardless of any status value sent in the request body. Use PATCH /jobs/{job_id}/ + to change the status to 'Active' (or any other value) after creation. + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/JobCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/JobCreate' + multipart/form-data: + schema: + $ref: '#/components/schemas/JobCreate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/JobCreate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/jobs/{job_id}/: + get: + operationId: dashboard_company_jobs_retrieve + description: Retrieve details of a specific job. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/JobList' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_company_jobs_partial_update + description: Update a specific job. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedJobUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedJobUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedJobUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/JobUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_company_jobs_destroy + description: Delete a specific job. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/company/jobs/{job_id}/analytics/: + get: + operationId: dashboard_company_jobs_analytics_retrieve + description: Fetches detailed view, application, and hired statistics for a + specific job posting. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/jobs/{job_id}/applications/: + get: + operationId: dashboard_company_jobs_applications_list + description: List all applications for a specific job (creator or company mentor). + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ApplicationTracking' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_company_jobs_applications_create + description: Apply to a job. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/JobApplication' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/JobApplication' + multipart/form-data: + schema: + $ref: '#/components/schemas/JobApplication' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/jobs/{job_id}/apply/: + get: + operationId: dashboard_company_jobs_apply_list + description: List all applications for a specific job (creator or company mentor). + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ApplicationTracking' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_company_jobs_apply_create + description: Apply to a job. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/JobApplication' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/JobApplication' + multipart/form-data: + schema: + $ref: '#/components/schemas/JobApplication' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/jobs/{job_id}/view/: + post: + operationId: dashboard_company_jobs_view_create + description: Increment the view count for a specific job listing. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/jobs/all/: + get: + operationId: dashboard_company_jobs_all_list + description: Public endpoint to list all active jobs. + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/JobList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/list/: + get: + operationId: dashboard_company_list_list + description: List all companies with filtering. + parameters: + - in: query + name: company_size + schema: + type: string + - in: query + name: country_id + schema: + type: string + format: uuid + description: Filter by country UUID + - in: query + name: district_id + schema: + type: string + format: uuid + description: Filter by district UUID + - in: query + name: industry_sector + schema: + type: string + - in: query + name: state_id + schema: + type: string + format: uuid + description: Filter by state UUID + - in: query + name: status + schema: + type: string + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/CompanyList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/mentor/list/: + get: + operationId: dashboard_company_mentor_list_list + description: List all Company Mentor nominations for the authenticated company. + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/CompanyMentorList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/mentor/nominate/: + post: + operationId: dashboard_company_mentor_nominate_create + description: Nominate a platform user as a Company Mentor for your company. + Provide the user's **muid** (e.g. `john-doe@mulearn`). The user must already + be a member of the company's organisation (i.e. they appear in `UserOrganizationLink` + for this company). Only the verified company creator can nominate. The nomination + enters PENDING state until an admin approves it. + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompanyMentorNominate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CompanyMentorNominate' + multipart/form-data: + schema: + $ref: '#/components/schemas/CompanyMentorNominate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyMentorList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/mulearners/: + get: + operationId: dashboard_company_mulearners_list + description: Directory of MuLearners available to companies (creator or company + mentor). + parameters: + - in: query + name: achievement + schema: + type: string + - in: query + name: college + schema: + type: string + - in: query + name: department + schema: + type: string + - in: query + name: graduation_year + schema: + type: string + - in: query + name: ig + schema: + type: string + - in: query + name: level + schema: + type: integer + - in: query + name: max_karma + schema: + type: integer + - in: query + name: min_karma + schema: + type: integer + - in: query + name: skill + schema: + type: string + - in: query + name: task + schema: + type: string + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/MulearnerDirectory' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/profile/: + get: + operationId: dashboard_company_profile_retrieve + description: Retrieve the profile of the authenticated company (creator or approved + company mentor). + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_company_profile_partial_update + description: Update the profile of the authenticated company (creator or approved + company mentor). + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedCompanyUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedCompanyUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedCompanyUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/profile/public/{slug}/: + get: + operationId: dashboard_company_profile_public_retrieve + description: Public endpoint to view a company's profile. + parameters: + - in: path + name: slug + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/PublicCompanyProfile' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/profile/public/{slug}/jobs/: + get: + operationId: dashboard_company_profile_public_jobs_list + description: Public endpoint to view all active jobs for a specific company. + parameters: + - in: path + name: slug + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/JobList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/register/: + post: + operationId: dashboard_company_register_create + description: Submit a new company registration. + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompanyRegister' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CompanyRegister' + multipart/form-data: + schema: + $ref: '#/components/schemas/CompanyRegister' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyRegister' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_company_register_partial_update + description: Update or resubmit a pending/rejected company registration. + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedCompanyUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedCompanyUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedCompanyUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/company/status/: + get: + operationId: dashboard_company_status_retrieve + description: Check the status of a company registration. + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/summary/: + get: + operationId: dashboard_company_summary_retrieve + description: Get summary stats for companies for the admin dashboard. + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/talent-pool/analytics/: + get: + operationId: dashboard_company_talent_pool_analytics_retrieve + description: Retrieve analytics data for the talent pool. + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/company/tasks/: + get: + operationId: dashboard_company_tasks_retrieve + description: List all tasks submitted by the authenticated company user (creator + or company mentor). Supports pagination, search, and sort. + parameters: + - in: query + name: approval_status + schema: + type: string + description: 'Filter by approval_status: pending | approved | rejected' + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyTaskListResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_company_tasks_create + description: Create a new task for the company. The task is saved with approval_status='pending' + and active=False until an admin approves it. Accepts an optional skill_ids + list. + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompanyTaskCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CompanyTaskCreate' + multipart/form-data: + schema: + $ref: '#/components/schemas/CompanyTaskCreate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Task submitted for approval. + /api/v1/dashboard/company/tasks/{task_id}/: + get: + operationId: dashboard_company_tasks_retrieve_2 + description: Retrieve the detail of a specific task submitted by the company. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyTaskList' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_company_tasks_update + description: Update a task submitted by the company. Regardless of the current + approval_status, the task reverts to 'pending' and active=False after editing. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CompanyTaskUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CompanyTaskUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/CompanyTaskUpdate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Task updated and re-submitted for approval. + patch: + operationId: dashboard_company_tasks_partial_update + description: Update a task submitted by the company (partial update). Regardless + of the current approval_status, the task reverts to pending and active=False. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedCompanyTaskPatch' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedCompanyTaskPatch' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedCompanyTaskPatch' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CompanyTaskList' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_company_tasks_destroy + description: Soft-delete a task submitted by the company by marking it inactive. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Company + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Task deleted successfully. + /api/v1/dashboard/company/verify/{company_id}/: + patch: + operationId: dashboard_company_verify_partial_update + description: Verify or reject a company. + parameters: + - in: path + name: company_id + schema: + type: string + required: true + tags: + - Company + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedCompanyVerify' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedCompanyVerify' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedCompanyVerify' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/coupon/verify-coupon/: + post: + operationId: dashboard_coupon_verify_coupon_create + description: Create Coupon Api. + tags: + - Coupon + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CouponApplyResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/discord-moderator/leaderboard/: + get: + operationId: dashboard_discord_moderator_leaderboard_retrieve + description: Retrieve Leader Board. + tags: + - Discord Moderator + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Leaderboard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/discord-moderator/pendingcounts/: + get: + operationId: dashboard_discord_moderator_pendingcounts_retrieve + description: Retrieve Pending Tasks. + tags: + - Discord Moderator + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/DiscordPendingTasksResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/discord-moderator/tasklist/: + get: + operationId: dashboard_discord_moderator_tasklist_retrieve + description: Retrieve Task List. + tags: + - Discord Moderator + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/KarmaActivityLog' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/district/college-details/: + get: + operationId: dashboard_district_college_details_retrieve + tags: + - District + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/district/college-details/csv/: + get: + operationId: dashboard_district_college_details_csv_retrieve + tags: + - District + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/district/district-details/: + get: + operationId: dashboard_district_district_details_retrieve + tags: + - District + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/district/student-details/: + get: + operationId: dashboard_district_student_details_retrieve + tags: + - District + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/district/student-details/csv/: + get: + operationId: dashboard_district_student_details_csv_retrieve + tags: + - District + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/district/student-level/: + get: + operationId: dashboard_district_student_level_retrieve + tags: + - District + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/district/top-campus/: + get: + operationId: dashboard_district_top_campus_retrieve + tags: + - District + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/dynamic-management/dynamic-role/: + get: + operationId: dashboard_dynamic_management_dynamic_role_retrieve + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_dynamic_management_dynamic_role_create + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_dynamic_management_dynamic_role_partial_update + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_dynamic_management_dynamic_role_destroy + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/dynamic-management/dynamic-role/create/: + get: + operationId: dashboard_dynamic_management_dynamic_role_create_retrieve + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_dynamic_management_dynamic_role_create_create + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_dynamic_management_dynamic_role_create_partial_update + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_dynamic_management_dynamic_role_create_destroy + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/dynamic-management/dynamic-role/delete/{type_id}/: + get: + operationId: dashboard_dynamic_management_dynamic_role_delete_retrieve + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_dynamic_management_dynamic_role_delete_create + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_dynamic_management_dynamic_role_delete_partial_update + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_dynamic_management_dynamic_role_delete_destroy + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/dynamic-management/dynamic-role/update/{type_id}/: + get: + operationId: dashboard_dynamic_management_dynamic_role_update_retrieve + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_dynamic_management_dynamic_role_update_create + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_dynamic_management_dynamic_role_update_partial_update + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_dynamic_management_dynamic_role_update_destroy + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/dynamic-management/dynamic-user/: + get: + operationId: dashboard_dynamic_management_dynamic_user_retrieve + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_dynamic_management_dynamic_user_create + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_dynamic_management_dynamic_user_partial_update + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_dynamic_management_dynamic_user_destroy + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/dynamic-management/dynamic-user/create/: + get: + operationId: dashboard_dynamic_management_dynamic_user_create_retrieve + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_dynamic_management_dynamic_user_create_create + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_dynamic_management_dynamic_user_create_partial_update + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_dynamic_management_dynamic_user_create_destroy + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/dynamic-management/dynamic-user/delete/{type_id}/: + get: + operationId: dashboard_dynamic_management_dynamic_user_delete_retrieve + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_dynamic_management_dynamic_user_delete_create + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_dynamic_management_dynamic_user_delete_partial_update + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_dynamic_management_dynamic_user_delete_destroy + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/dynamic-management/dynamic-user/update/{type_id}/: + get: + operationId: dashboard_dynamic_management_dynamic_user_update_retrieve + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_dynamic_management_dynamic_user_update_create + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_dynamic_management_dynamic_user_update_partial_update + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_dynamic_management_dynamic_user_update_destroy + parameters: + - in: path + name: type_id + schema: + type: string + required: true + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/dynamic-management/roles/: + get: + operationId: dashboard_dynamic_management_roles_retrieve + description: Retrieve Role Drop Down. + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/RoleDropDown' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/dynamic-management/types/: + get: + operationId: dashboard_dynamic_management_types_retrieve + description: Retrieve Dynamic Type Drop Down. + tags: + - Dynamic Management + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/DynamicTypeDropDownResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/enabler/campuses/: + get: + operationId: dashboard_enabler_campuses_retrieve + tags: + - Enabler + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/enabler/campuses/{campus_id}/notes/: + get: + operationId: dashboard_enabler_campuses_notes_retrieve + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + tags: + - Enabler + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_enabler_campuses_notes_create + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + tags: + - Enabler + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_enabler_campuses_notes_partial_update + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + tags: + - Enabler + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_enabler_campuses_notes_destroy + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + tags: + - Enabler + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/enabler/campuses/{campus_id}/review/: + get: + operationId: dashboard_enabler_campuses_review_retrieve + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + tags: + - Enabler + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/enabler/home-summary/: + get: + operationId: dashboard_enabler_home_summary_retrieve + tags: + - Enabler + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/enabler/reports/: + get: + operationId: dashboard_enabler_reports_retrieve + tags: + - Enabler + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/error-log/: + get: + operationId: dashboard_error_log_retrieve + description: |- + API view for logging errors. + + Args: + request: The HTTP request object. + + Returns: + CustomResponse: The response object containing formatted error logs. + + Raises: + IOError: If there is an error reading the error log file. + + Examples: + >>> logger_api = LoggerAPI() + >>> response = logger_api.get(request) + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_error_log_partial_update + description: |- + API view for logging errors. + + Args: + request: The HTTP request object. + + Returns: + CustomResponse: The response object containing formatted error logs. + + Raises: + IOError: If there is an error reading the error log file. + + Examples: + >>> logger_api = LoggerAPI() + >>> response = logger_api.get(request) + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/error-log/{log_name}/: + get: + operationId: dashboard_error_log_retrieve_2 + parameters: + - in: path + name: log_name + schema: + type: string + required: true + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/error-log/clear/{log_name}/: + post: + operationId: dashboard_error_log_clear_create + parameters: + - in: path + name: log_name + schema: + type: string + required: true + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/error-log/graph/: + get: + operationId: dashboard_error_log_graph_retrieve + description: |- + A class representing the ErrorGraphAPI view. + + This view handles the GET request to retrieve formatted error data including a heatmap of URL hits, + incident information, and affected users. It requires authentication and specific roles to access. + + Args: + self: The instance of the class itself. + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/error-log/patch/{error_id}/: + get: + operationId: dashboard_error_log_patch_retrieve + description: |- + API view for logging errors. + + Args: + request: The HTTP request object. + + Returns: + CustomResponse: The response object containing formatted error logs. + + Raises: + IOError: If there is an error reading the error log file. + + Examples: + >>> logger_api = LoggerAPI() + >>> response = logger_api.get(request) + parameters: + - in: path + name: error_id + schema: + type: string + required: true + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_error_log_patch_partial_update + description: |- + API view for logging errors. + + Args: + request: The HTTP request object. + + Returns: + CustomResponse: The response object containing formatted error logs. + + Raises: + IOError: If there is an error reading the error log file. + + Examples: + >>> logger_api = LoggerAPI() + >>> response = logger_api.get(request) + parameters: + - in: path + name: error_id + schema: + type: string + required: true + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/error-log/tab/: + get: + operationId: dashboard_error_log_tab_retrieve + description: |- + A class representing the ErrorTabAPI view. + + This view handles the GET request to retrieve grouped URL patterns based on user roles. + It requires authentication and specific roles to access. + + Args: + self: The instance of the class itself. + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/error-log/view/{log_name}/: + get: + operationId: dashboard_error_log_view_retrieve + parameters: + - in: path + name: log_name + schema: + type: string + required: true + tags: + - Error Log + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/: + get: + operationId: dashboard_events_retrieve + description: Retrieve Event List. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/{event_id}/: + get: + operationId: dashboard_events_retrieve_2 + description: Retrieve Event Detail. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/{event_id}/interest/: + post: + operationId: dashboard_events_interest_create + description: Create Event Interest. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventInterestCreateResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_events_interest_destroy + description: Delete Event Interest. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: Interest removed successfully. + /api/v1/dashboard/events/admin/: + get: + operationId: dashboard_events_admin_retrieve + description: |- + GET /events/admin/ + Returns ALL events on the platform (all statuses, including cancelled). + Supports additional admin filters: organiser_type, created_by. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/admin/{event_id}/approve/: + post: + operationId: dashboard_events_admin_approve_create + description: |- + POST /events/admin//approve/ + Advances a pending event through the approval pipeline. + + Transitions: + pending_campus_approval → pending_approval + pending_approval → published + pending_mentor_approval → published + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/admin/{event_id}/feature/: + patch: + operationId: dashboard_events_admin_feature_partial_update + description: |- + PATCH /events/admin//feature/ + Toggles is_featured on/off. + Optionally accepts body: { "is_featured": true/false } + If not provided, current value is toggled. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/admin/{event_id}/reject/: + post: + operationId: dashboard_events_admin_reject_create + description: |- + POST /events/admin//reject/ + Rejects a pending event, changing its status to 'rejected'. + Body: { "reason": "..." } + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/calendar/: + get: + operationId: dashboard_events_calendar_retrieve + description: Retrieve Event Calendar. + tags: + - Events + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCalendarItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/campus-ig/{campus_ig_id}/: + get: + operationId: dashboard_events_campus_ig_retrieve + description: Retrieve Campus I G Event List. + parameters: + - in: path + name: campus_ig_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/campus/{campus_id}/: + get: + operationId: dashboard_events_campus_retrieve + description: Retrieve Campus Event List. + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/campus/{event_id}/approve/: + post: + operationId: dashboard_events_campus_approve_create + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/campus/{event_id}/reject/: + post: + operationId: dashboard_events_campus_reject_create + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/company/{company_id}/: + get: + operationId: dashboard_events_company_retrieve + description: Retrieve Company Event List. + parameters: + - in: path + name: company_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/featured/: + get: + operationId: dashboard_events_featured_retrieve + description: Retrieve Event Featured. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/ig/{ig_id}/: + get: + operationId: dashboard_events_ig_retrieve + description: Retrieve I G Event List. + parameters: + - in: path + name: ig_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/ig/cluster/{cluster}/: + get: + operationId: dashboard_events_ig_cluster_retrieve + description: Retrieve Cluster Event List. + parameters: + - in: path + name: cluster + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/is-featured/: + get: + operationId: dashboard_events_is_featured_retrieve + description: Retrieve Event Featured. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/: + get: + operationId: dashboard_events_manage_retrieve + description: Retrieve Manage Event List Create. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_events_manage_create + description: Create Manage Event List Create. + tags: + - Events + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/EventWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/EventWrite' + application/json: + schema: + $ref: '#/components/schemas/EventWrite' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/: + get: + operationId: dashboard_events_manage_retrieve_2 + description: Retrieve Manage Event Detail. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_events_manage_update + description: Update Manage Event Detail. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_events_manage_partial_update + description: Partially update Manage Event Detail. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_events_manage_destroy + description: Delete Manage Event Detail. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/analytics/: + get: + operationId: dashboard_events_manage_analytics_retrieve + description: Retrieve analytics and performance stats for an event. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventAnalyticsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/co-owners/: + get: + operationId: dashboard_events_manage_co_owners_retrieve + description: Retrieve Manage Event Co Owner. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCoOwner' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_events_manage_co_owners_create + description: Create Manage Event Co Owner. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCoOwner' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/co-owners/{co_owner_id}/: + delete: + operationId: dashboard_events_manage_co_owners_destroy + description: Delete Manage Event Co Owner Remove. + parameters: + - in: path + name: co_owner_id + schema: + type: string + required: true + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCoOwner' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/collaborators/: + get: + operationId: dashboard_events_manage_collaborators_retrieve + description: Retrieve Manage Event Collaborator. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCollaborator' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_events_manage_collaborators_create + description: Create Manage Event Collaborator. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCollaborator' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/collaborators/{collaborator_id}/: + delete: + operationId: dashboard_events_manage_collaborators_destroy + description: Delete Manage Event Collaborator Remove. + parameters: + - in: path + name: collaborator_id + schema: + type: string + required: true + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCollaborator' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/collaborators/{collaborator_id}/accept/: + post: + operationId: dashboard_events_manage_collaborators_accept_create + description: Create Manage Event Collaborator Accept. + parameters: + - in: path + name: collaborator_id + schema: + type: string + required: true + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCollaborator' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/collaborators/{collaborator_id}/reject/: + post: + operationId: dashboard_events_manage_collaborators_reject_create + description: Create Manage Event Collaborator Reject. + parameters: + - in: path + name: collaborator_id + schema: + type: string + required: true + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCollaborator' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/publish/: + post: + operationId: dashboard_events_manage_publish_create + description: Create Manage Event Publish. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventPublishResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/tasks/: + get: + operationId: dashboard_events_manage_tasks_list + description: List all tasks linked to an event. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/EventTask' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_events_manage_tasks_create + description: Create a new task linked to an event. Task will require admin approval. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/EventTaskWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/EventTaskWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/EventTaskWrite' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventTask' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/tasks/{task_id}/: + get: + operationId: dashboard_events_manage_tasks_retrieve + description: Retrieve a specific task linked to an event. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventTask' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_events_manage_tasks_partial_update + description: Partially update a task linked to an event. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Events + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedEventTaskWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedEventTaskWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedEventTaskWrite' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventTask' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_events_manage_tasks_destroy + description: Delete a task linked to an event. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventTaskDeleteResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/manage/{event_id}/tasks/meta/: + get: + operationId: dashboard_events_manage_tasks_meta_retrieve + description: Retrieve dropdown metadata for event task forms. + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventTaskMetaResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/mentor/{event_id}/approve/: + post: + operationId: dashboard_events_mentor_approve_create + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/mentor/{event_id}/reject/: + post: + operationId: dashboard_events_mentor_reject_create + parameters: + - in: path + name: event_id + schema: + type: string + required: true + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/events/meta/categories/: + get: + operationId: dashboard_events_meta_categories_list + description: Retrieve Event Categories. + tags: + - Events + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/EventCategoryItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/meta/collaboration-targets/: + get: + operationId: dashboard_events_meta_collaboration_targets_retrieve + description: Retrieve Collaboration Targets. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventCollaborationTargets' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/meta/event-type-scope/: + get: + operationId: dashboard_events_meta_event_type_scope_retrieve + description: Retrieve Event Types and Scopes. + tags: + - Events + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventTypesScopesResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/meta/organizer-options/: + get: + operationId: dashboard_events_meta_organizer_options_retrieve + description: Retrieve Organizer Options. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EventOrganizerOptions' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/my-invites/: + get: + operationId: dashboard_events_my_invites_retrieve + description: Retrieve My Event Invites. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MyEventInvite' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/events/tasks/: + get: + operationId: dashboard_events_tasks_list + description: Scope-aware list of tasks for events the caller can access. Unauthenticated + callers receive only global-event tasks. + tags: + - Events + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/TaskListPublic' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/home/learner/streak/: + get: + operationId: dashboard_home_learner_streak_retrieve + description: Retrieve Learner Streak. + tags: + - Home + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/HomeLearnerStreakResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/home/learner/summary/: + get: + operationId: dashboard_home_learner_summary_retrieve + description: Retrieve Learner Dashboard Summary. + tags: + - Home + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/HomeLearnerDashboardSummaryResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/ig/: + get: + operationId: dashboard_ig_retrieve + description: Retrieve Interest Group. + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InterestGroup' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_ig_create + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_ig_update + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_ig_destroy + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/ig/{id}/: + get: + operationId: dashboard_ig_retrieve_2 + description: Retrieve Interest Group. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InterestGroup' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_ig_create_2 + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_ig_update_2 + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_ig_destroy_2 + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/ig/{id}/join/: + post: + operationId: dashboard_ig_join_create + description: Join Interest Group instantly. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InterestGroup' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_ig_join_destroy + description: Leave Interest Group instantly. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InterestGroup' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/ig/{id}/leave/: + post: + operationId: dashboard_ig_leave_create + description: Join Interest Group instantly. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InterestGroup' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_ig_leave_destroy + description: Leave Interest Group instantly. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InterestGroup' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/ig/csv/: + get: + operationId: dashboard_ig_csv_retrieve + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/ig/get/{id}/: + get: + operationId: dashboard_ig_get_retrieve + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_ig_get_partial_update + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/ig/list/: + get: + operationId: dashboard_ig_list_retrieve + description: Retrieve Interest Group List Api. + tags: + - Ig + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InterestGroup' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/ig/request/: + get: + operationId: dashboard_ig_request_retrieve + description: API endpoint for users to submit and retrieve IG creation requests. + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_ig_request_create + description: API endpoint for users to submit and retrieve IG creation requests. + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_ig_request_partial_update + description: API endpoint for users to submit and retrieve IG creation requests. + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_ig_request_destroy + description: API endpoint for users to submit and retrieve IG creation requests. + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/ig/request/{id}/: + get: + operationId: dashboard_ig_request_retrieve_2 + description: API endpoint for users to submit and retrieve IG creation requests. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_ig_request_create_2 + description: API endpoint for users to submit and retrieve IG creation requests. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_ig_request_partial_update_2 + description: API endpoint for users to submit and retrieve IG creation requests. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_ig_request_destroy_2 + description: API endpoint for users to submit and retrieve IG creation requests. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Ig + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/intern/guilds/: + get: + operationId: dashboard_intern_guilds_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/leaderboard/: + get: + operationId: dashboard_intern_leaderboard_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/leaderboard/me/: + get: + operationId: dashboard_intern_leaderboard_me_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/leave/: + get: + operationId: dashboard_intern_leave_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_leave_create + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_intern_leave_partial_update + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/leave/{leave_id}/: + get: + operationId: dashboard_intern_leave_retrieve_2 + parameters: + - in: path + name: leave_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_leave_create_2 + parameters: + - in: path + name: leave_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_intern_leave_partial_update_2 + parameters: + - in: path + name: leave_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/leave/{leave_id}/cancel/: + get: + operationId: dashboard_intern_leave_cancel_retrieve + parameters: + - in: path + name: leave_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_leave_cancel_create + parameters: + - in: path + name: leave_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_intern_leave_cancel_partial_update + parameters: + - in: path + name: leave_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/leave/balance/: + get: + operationId: dashboard_intern_leave_balance_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/leave/history/: + get: + operationId: dashboard_intern_leave_history_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/minutes/: + get: + operationId: dashboard_intern_minutes_retrieve + description: |- + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_minutes_create + description: |- + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_intern_minutes_update + description: |- + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_intern_minutes_destroy + description: |- + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/intern/minutes/{minute_id}/: + get: + operationId: dashboard_intern_minutes_retrieve_2 + description: |- + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + parameters: + - in: path + name: minute_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_minutes_create_2 + description: |- + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + parameters: + - in: path + name: minute_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_intern_minutes_update_2 + description: |- + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + parameters: + - in: path + name: minute_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_intern_minutes_destroy_2 + description: |- + Intern Lead's guild minutes endpoint. + + GET /api/v1/intern/minutes/ — List minutes (filterable by guild/date) + POST /api/v1/intern/minutes/ — Upload new guild minutes (Intern Lead only) + PUT /api/v1/intern/minutes// — Edit existing minutes (Intern Lead only) + DELETE /api/v1/intern/minutes// — Delete minutes (Intern Lead only) + parameters: + - in: path + name: minute_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/intern/overview/activity/: + get: + operationId: dashboard_intern_overview_activity_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/overview/leaderboard/top/: + get: + operationId: dashboard_intern_overview_leaderboard_top_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/overview/status/: + get: + operationId: dashboard_intern_overview_status_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/reviews/: + get: + operationId: dashboard_intern_reviews_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_reviews_create + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_intern_reviews_partial_update + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/reviews/{review_id}/: + get: + operationId: dashboard_intern_reviews_retrieve_2 + parameters: + - in: path + name: review_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_reviews_create_2 + parameters: + - in: path + name: review_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_intern_reviews_partial_update_2 + parameters: + - in: path + name: review_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/reviews/current/: + get: + operationId: dashboard_intern_reviews_current_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/reviews/history/: + get: + operationId: dashboard_intern_reviews_history_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/reviews/prefill/: + get: + operationId: dashboard_intern_reviews_prefill_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/tasks/{task_id}/: + patch: + operationId: dashboard_intern_tasks_partial_update + description: |- + Allows an intern to directly set task status and output_link + without going through a timesheet submission. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/tasks/{task_id}/detail/: + get: + operationId: dashboard_intern_tasks_detail_retrieve + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/tasks/{task_id}/submit/: + patch: + operationId: dashboard_intern_tasks_submit_partial_update + description: |- + Allows an intern to directly set task status and output_link + without going through a timesheet submission. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/tasks/categories/: + get: + operationId: dashboard_intern_tasks_categories_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/tasks/mine/: + get: + operationId: dashboard_intern_tasks_mine_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/timesheets/: + get: + operationId: dashboard_intern_timesheets_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_timesheets_create + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_intern_timesheets_partial_update + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/timesheets/{timesheet_id}/: + get: + operationId: dashboard_intern_timesheets_retrieve_2 + parameters: + - in: path + name: timesheet_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_intern_timesheets_create_2 + parameters: + - in: path + name: timesheet_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_intern_timesheets_partial_update_2 + parameters: + - in: path + name: timesheet_id + schema: + type: string + required: true + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/timesheets/history/: + get: + operationId: dashboard_intern_timesheets_history_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/timesheets/prefill/: + get: + operationId: dashboard_intern_timesheets_prefill_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/timesheets/summary/: + get: + operationId: dashboard_intern_timesheets_summary_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/intern/timesheets/today/: + get: + operationId: dashboard_intern_timesheets_today_retrieve + tags: + - Intern + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/karma-voucher/: + get: + operationId: dashboard_karma_voucher_retrieve + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_karma_voucher_create + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_karma_voucher_partial_update + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_karma_voucher_destroy + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/karma-voucher/base-template/: + get: + operationId: dashboard_karma_voucher_base_template_retrieve + description: Retrieve Voucher Base Template. + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: XLSX file download + /api/v1/dashboard/karma-voucher/create/: + get: + operationId: dashboard_karma_voucher_create_retrieve + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_karma_voucher_create_create + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_karma_voucher_create_partial_update + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_karma_voucher_create_destroy + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/karma-voucher/delete/{voucher_id}/: + get: + operationId: dashboard_karma_voucher_delete_retrieve + parameters: + - in: path + name: voucher_id + schema: + type: string + required: true + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_karma_voucher_delete_create + parameters: + - in: path + name: voucher_id + schema: + type: string + required: true + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_karma_voucher_delete_partial_update + parameters: + - in: path + name: voucher_id + schema: + type: string + required: true + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_karma_voucher_delete_destroy + parameters: + - in: path + name: voucher_id + schema: + type: string + required: true + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/karma-voucher/export/: + get: + operationId: dashboard_karma_voucher_export_retrieve + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/karma-voucher/import/: + post: + operationId: dashboard_karma_voucher_import_create + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/karma-voucher/update/{voucher_id}/: + get: + operationId: dashboard_karma_voucher_update_retrieve + parameters: + - in: path + name: voucher_id + schema: + type: string + required: true + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_karma_voucher_update_create + parameters: + - in: path + name: voucher_id + schema: + type: string + required: true + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_karma_voucher_update_partial_update + parameters: + - in: path + name: voucher_id + schema: + type: string + required: true + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_karma_voucher_update_destroy + parameters: + - in: path + name: voucher_id + schema: + type: string + required: true + tags: + - Karma Voucher + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/learningcircle/create/: + get: + operationId: dashboard_learningcircle_create_retrieve + description: Retrieve Learning Circle. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_create_create + description: Create Learning Circle. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_learningcircle_create_update + description: Update Learning Circle. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_learningcircle_create_destroy + description: Delete Learning Circle. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/delete/{circle_id}/: + get: + operationId: dashboard_learningcircle_delete_retrieve + description: Retrieve Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_delete_create + description: Create Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_learningcircle_delete_update + description: Update Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_learningcircle_delete_destroy + description: Delete Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/edit/{circle_id}/: + get: + operationId: dashboard_learningcircle_edit_retrieve + description: Retrieve Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_edit_create + description: Create Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_learningcircle_edit_update + description: Update Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_learningcircle_edit_destroy + description: Delete Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/info/{circle_id}/: + get: + operationId: dashboard_learningcircle_info_retrieve + description: Retrieve Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_info_create + description: Create Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_learningcircle_info_update + description: Update Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_learningcircle_info_destroy + description: Delete Learning Circle. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/invite/{circle_id}/: + post: + operationId: dashboard_learningcircle_invite_create + description: Create Circle Invite. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CircleInvite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CircleInvite' + multipart/form-data: + schema: + $ref: '#/components/schemas/CircleInvite' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleInvite' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/invite/sent/{circle_id}/: + get: + operationId: dashboard_learningcircle_invite_sent_retrieve + description: Retrieve Circle Sent Invites. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleSentInvites' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/invite/status/: + get: + operationId: dashboard_learningcircle_invite_status_retrieve + description: Retrieve Circle Invite Status. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleInviteStatus' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_invite_status_create + description: Create Circle Invite Status. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleInviteStatus' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/invite/status/{link_id}/: + get: + operationId: dashboard_learningcircle_invite_status_retrieve_2 + description: Retrieve Circle Invite Status. + parameters: + - in: path + name: link_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleInviteStatus' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_invite_status_create_2 + description: Create Circle Invite Status. + parameters: + - in: path + name: link_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleInviteStatus' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/join/{circle_id}/: + get: + operationId: dashboard_learningcircle_join_retrieve + description: Retrieve Circle Join. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleJoinRequest' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_join_create + description: Create Circle Join. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleJoinRequest' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_learningcircle_join_partial_update + description: Partially update Circle Join. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleJoinRequest' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/list/: + get: + operationId: dashboard_learningcircle_list_retrieve + description: Retrieve Learning Circle. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_list_create + description: Create Learning Circle. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_learningcircle_list_update + description: Update Learning Circle. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_learningcircle_list_destroy + description: Delete Learning Circle. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/meeting/attendee-report/{meet_id}/: + get: + operationId: dashboard_learningcircle_meeting_attendee_report_retrieve + description: Retrieve Learning Circle Attendee Report. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleAttendeeReportGetResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_meeting_attendee_report_create + description: Create Learning Circle Attendee Report. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Attendee report submitted successfully. No response body. + delete: + operationId: dashboard_learningcircle_meeting_attendee_report_destroy + description: Delete Learning Circle Attendee Report. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Attendee report deleted successfully. No response body. + /api/v1/dashboard/learningcircle/meeting/create/{circle_id}/: + post: + operationId: dashboard_learningcircle_meeting_create_create + description: Create Learning Circle Meeting. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + multipart/form-data: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_learningcircle_meeting_create_update + description: Update Learning Circle Meeting. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_learningcircle_meeting_create_destroy + description: Delete Learning Circle Meeting. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/meeting/delete/{meet_id}/: + post: + operationId: dashboard_learningcircle_meeting_delete_create + description: Create Learning Circle Meeting. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + multipart/form-data: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_learningcircle_meeting_delete_update + description: Update Learning Circle Meeting. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_learningcircle_meeting_delete_destroy + description: Delete Learning Circle Meeting. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/meeting/edit/{meet_id}/: + post: + operationId: dashboard_learningcircle_meeting_edit_create + description: Create Learning Circle Meeting. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + multipart/form-data: + schema: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_learningcircle_meeting_edit_update + description: Update Learning Circle Meeting. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_learningcircle_meeting_edit_destroy + description: Delete Learning Circle Meeting. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetingLogCreateEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/meeting/info/{meet_id}/: + get: + operationId: dashboard_learningcircle_meeting_info_retrieve + description: Retrieve Learning Circle Meeting Info. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetupInfo' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/meeting/join/{meet_id}/: + post: + operationId: dashboard_learningcircle_meeting_join_create + description: Create Learning Circle Join. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Joined the meeting successfully. No response body. + delete: + operationId: dashboard_learningcircle_meeting_join_destroy + description: Delete Learning Circle Join. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Removed from meetup attendee list. No response body. + /api/v1/dashboard/learningcircle/meeting/leave/{meet_id}/: + post: + operationId: dashboard_learningcircle_meeting_leave_create + description: Create Learning Circle Join. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Joined the meeting successfully. No response body. + delete: + operationId: dashboard_learningcircle_meeting_leave_destroy + description: Delete Learning Circle Join. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Removed from meetup attendee list. No response body. + /api/v1/dashboard/learningcircle/meeting/list/: + get: + operationId: dashboard_learningcircle_meeting_list_retrieve + description: Retrieve Learning Circle Meeting List. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetupMin' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/meeting/list-public/: + get: + operationId: dashboard_learningcircle_meeting_list_public_retrieve + description: Retrieve Learning Circle Meeting Public List. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetupPublic' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/meeting/list/{circle_id}/: + get: + operationId: dashboard_learningcircle_meeting_list_retrieve_2 + description: Retrieve Learning Circle Meeting List. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CircleMeetupMin' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/meeting/report/{meet_id}/: + get: + operationId: dashboard_learningcircle_meeting_report_retrieve + description: Retrieve Learning Circle Report. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleReportGetResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_learningcircle_meeting_report_create + description: Create Learning Circle Report. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Meeting report submitted successfully. No response body. + delete: + operationId: dashboard_learningcircle_meeting_report_destroy + description: Delete Learning Circle Report. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Meeting report deleted successfully. No response body. + /api/v1/dashboard/learningcircle/meeting/rsvp/{meet_id}/: + post: + operationId: dashboard_learningcircle_meeting_rsvp_create + description: Create Learning Circle R S V P. + parameters: + - in: path + name: meet_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: RSVP registered successfully. No response body. + /api/v1/dashboard/learningcircle/members/{circle_id}/: + get: + operationId: dashboard_learningcircle_members_retrieve + description: Retrieve Learning Circle Member Details. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleMemberDetailsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/learningcircle/members/add/{circle_id}/: + post: + operationId: dashboard_learningcircle_members_add_create + description: Create Circle Member Add. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Member added to the circle successfully. No response body. + /api/v1/dashboard/learningcircle/transfer-lead/{circle_id}/: + post: + operationId: dashboard_learningcircle_transfer_lead_create + description: Create Circle Transfer Lead. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Circle lead transferred successfully. No response body. + /api/v1/dashboard/learningcircle/user-circles/: + get: + operationId: dashboard_learningcircle_user_circles_retrieve + description: Retrieve User Circle List. + tags: + - Learningcircle + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserCircleList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/location/countries/: + get: + operationId: dashboard_location_countries_retrieve + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + post: + operationId: dashboard_location_countries_create + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_location_countries_partial_update + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_location_countries_destroy + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/location/countries/{country_id}/: + get: + operationId: dashboard_location_countries_retrieve_2 + parameters: + - in: path + name: country_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + post: + operationId: dashboard_location_countries_create_2 + parameters: + - in: path + name: country_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_location_countries_partial_update_2 + parameters: + - in: path + name: country_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_location_countries_destroy_2 + parameters: + - in: path + name: country_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/location/countries/list/: + get: + operationId: dashboard_location_countries_list_list + description: Retrieve Country List Api. + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/LocationCountryListResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/location/districts/: + get: + operationId: dashboard_location_districts_retrieve + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + post: + operationId: dashboard_location_districts_create + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_location_districts_partial_update + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_location_districts_destroy + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/location/districts/{district_id}/: + get: + operationId: dashboard_location_districts_retrieve_2 + parameters: + - in: path + name: district_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + post: + operationId: dashboard_location_districts_create_2 + parameters: + - in: path + name: district_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_location_districts_partial_update_2 + parameters: + - in: path + name: district_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_location_districts_destroy_2 + parameters: + - in: path + name: district_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/location/states/: + get: + operationId: dashboard_location_states_retrieve + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + post: + operationId: dashboard_location_states_create + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_location_states_partial_update + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_location_states_destroy + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/location/states/{state_id}/: + get: + operationId: dashboard_location_states_retrieve_2 + parameters: + - in: path + name: state_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + post: + operationId: dashboard_location_states_create_2 + parameters: + - in: path + name: state_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_location_states_partial_update_2 + parameters: + - in: path + name: state_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_location_states_destroy_2 + parameters: + - in: path + name: state_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/location/states/list/: + get: + operationId: dashboard_location_states_list_list + description: Retrieve State List Api. + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/LocationStateListResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/location/zones/: + get: + operationId: dashboard_location_zones_retrieve + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + post: + operationId: dashboard_location_zones_create + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_location_zones_partial_update + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_location_zones_destroy + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/location/zones/{zone_id}/: + get: + operationId: dashboard_location_zones_retrieve_2 + parameters: + - in: path + name: zone_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + post: + operationId: dashboard_location_zones_create_2 + parameters: + - in: path + name: zone_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_location_zones_partial_update_2 + parameters: + - in: path + name: zone_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_location_zones_destroy_2 + parameters: + - in: path + name: zone_id + schema: + type: string + required: true + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/location/zones/list/: + get: + operationId: dashboard_location_zones_list_list + description: Retrieve Zone List Api. + tags: + - Location + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/LocationZoneListResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/manage-interns/interns/: + get: + operationId: dashboard_manage_interns_interns_retrieve + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_manage_interns_interns_create + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_manage_interns_interns_partial_update + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_manage_interns_interns_destroy + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/manage-interns/interns/{intern_id}/: + get: + operationId: dashboard_manage_interns_interns_retrieve_2 + parameters: + - in: path + name: intern_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_manage_interns_interns_create_2 + parameters: + - in: path + name: intern_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_manage_interns_interns_partial_update_2 + parameters: + - in: path + name: intern_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_manage_interns_interns_destroy_2 + parameters: + - in: path + name: intern_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/manage-interns/interns/export/: + get: + operationId: dashboard_manage_interns_interns_export_retrieve + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/interns/import/: + post: + operationId: dashboard_manage_interns_interns_import_create + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/interns/import/template/: + get: + operationId: dashboard_manage_interns_interns_import_template_retrieve + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/leave/: + get: + operationId: dashboard_manage_interns_leave_retrieve + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/leave/{leave_id}/: + get: + operationId: dashboard_manage_interns_leave_retrieve_2 + parameters: + - in: path + name: leave_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/leave/{leave_id}/review/: + patch: + operationId: dashboard_manage_interns_leave_review_partial_update + parameters: + - in: path + name: leave_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/reviews/: + get: + operationId: dashboard_manage_interns_reviews_retrieve + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/reviews/reviews/{review_id}/review/: + get: + operationId: dashboard_manage_interns_reviews_reviews_review_retrieve + parameters: + - in: path + name: review_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_manage_interns_reviews_reviews_review_partial_update + parameters: + - in: path + name: review_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/reviews/timesheets/: + get: + operationId: dashboard_manage_interns_reviews_timesheets_retrieve + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/reviews/timesheets/{timesheet_id}/review/: + get: + operationId: dashboard_manage_interns_reviews_timesheets_review_retrieve + parameters: + - in: path + name: timesheet_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_manage_interns_reviews_timesheets_review_partial_update + parameters: + - in: path + name: timesheet_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/status/: + get: + operationId: dashboard_manage_interns_status_retrieve + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/tasks/: + get: + operationId: dashboard_manage_interns_tasks_retrieve + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_manage_interns_tasks_create + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_manage_interns_tasks_partial_update + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_manage_interns_tasks_destroy + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/manage-interns/tasks/{task_id}/: + get: + operationId: dashboard_manage_interns_tasks_retrieve_2 + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_manage_interns_tasks_create_2 + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_manage_interns_tasks_partial_update_2 + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_manage_interns_tasks_destroy_2 + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/manage-interns/tasks/{task_id}/verify/: + post: + operationId: dashboard_manage_interns_tasks_verify_create + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/manage-interns/tasks/by-intern/{muid}/: + get: + operationId: dashboard_manage_interns_tasks_by_intern_retrieve + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Manage Interns + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/media-content/bulk/export/{content_type}/: + get: + operationId: dashboard_media_content_bulk_export_retrieve + parameters: + - in: path + name: content_type + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/media-content/bulk/import/: + post: + operationId: dashboard_media_content_bulk_import_create + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/media-content/inspiration-station/: + get: + operationId: dashboard_media_content_inspiration_station_retrieve + description: |- + GET /media-content/inspiration-station/ — Public paginated list. + POST /media-content/inspiration-station/ — Create an episode (Admin only). + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_media_content_inspiration_station_create + description: |- + GET /media-content/inspiration-station/ — Public paginated list. + POST /media-content/inspiration-station/ — Create an episode (Admin only). + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/media-content/inspiration-station/{record_id}/: + get: + operationId: dashboard_media_content_inspiration_station_retrieve_2 + description: |- + GET /media-content/inspiration-station// + PATCH /media-content/inspiration-station// (Admin) + DELETE /media-content/inspiration-station// (Admin) + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_media_content_inspiration_station_partial_update + description: |- + GET /media-content/inspiration-station// + PATCH /media-content/inspiration-station// (Admin) + DELETE /media-content/inspiration-station// (Admin) + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_media_content_inspiration_station_destroy + description: |- + GET /media-content/inspiration-station// + PATCH /media-content/inspiration-station// (Admin) + DELETE /media-content/inspiration-station// (Admin) + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/media-content/office-hours/: + get: + operationId: dashboard_media_content_office_hours_retrieve + description: |- + GET /media-content/office-hours/ — Public paginated list of sessions. + POST /media-content/office-hours/ — Create a session (Admin only). + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_media_content_office_hours_create + description: |- + GET /media-content/office-hours/ — Public paginated list of sessions. + POST /media-content/office-hours/ — Create a session (Admin only). + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/media-content/office-hours/{record_id}/: + get: + operationId: dashboard_media_content_office_hours_retrieve_2 + description: |- + GET /media-content/office-hours// — Public session detail. + PATCH /media-content/office-hours// — Partial update (Admin only). + DELETE /media-content/office-hours// — Soft-delete (Admin only). + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_media_content_office_hours_partial_update + description: |- + GET /media-content/office-hours// — Public session detail. + PATCH /media-content/office-hours// — Partial update (Admin only). + DELETE /media-content/office-hours// — Soft-delete (Admin only). + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_media_content_office_hours_destroy + description: |- + GET /media-content/office-hours// — Public session detail. + PATCH /media-content/office-hours// — Partial update (Admin only). + DELETE /media-content/office-hours// — Soft-delete (Admin only). + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/media-content/salt-mango-tree/: + get: + operationId: dashboard_media_content_salt_mango_tree_retrieve + description: |- + GET /media-content/salt-mango-tree/ — Public paginated list of episodes. + POST /media-content/salt-mango-tree/ — Create an episode (Admin only). + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_media_content_salt_mango_tree_create + description: |- + GET /media-content/salt-mango-tree/ — Public paginated list of episodes. + POST /media-content/salt-mango-tree/ — Create an episode (Admin only). + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/media-content/salt-mango-tree/{record_id}/: + get: + operationId: dashboard_media_content_salt_mango_tree_retrieve_2 + description: |- + GET /media-content/salt-mango-tree// + PATCH /media-content/salt-mango-tree// (Admin) + DELETE /media-content/salt-mango-tree// (Admin) + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_media_content_salt_mango_tree_partial_update + description: |- + GET /media-content/salt-mango-tree// + PATCH /media-content/salt-mango-tree// (Admin) + DELETE /media-content/salt-mango-tree// (Admin) + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_media_content_salt_mango_tree_destroy + description: |- + GET /media-content/salt-mango-tree// + PATCH /media-content/salt-mango-tree// (Admin) + DELETE /media-content/salt-mango-tree// (Admin) + parameters: + - in: path + name: record_id + schema: + type: string + required: true + tags: + - Media Content + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/mentor/{mentor_id}/grants/: + get: + operationId: dashboard_mentor_grants_list + description: List all scope grants for a mentor. + parameters: + - in: path + name: mentor_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/MentorScopeGrant' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/{mentor_id}/grants/{grant_id}/: + delete: + operationId: dashboard_mentor_grants_destroy + description: Revoke a single mentor scope grant. + parameters: + - in: path + name: grant_id + schema: + type: string + required: true + - in: path + name: mentor_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/mentor/activity/: + get: + operationId: dashboard_mentor_activity_list + description: Get recent activity of the currently logged-in mentor (sessions + created, tasks appraised). + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/MentorActivity' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/admin/assign/: + post: + operationId: dashboard_mentor_admin_assign_create + description: |- + Admin-only endpoint for bulk assigning / revoking mentor status. + + POST /api/v1/mentor/admin/assign/ + Body: { user_muids, mentor_tier, [org_id], [ig_ids], [about], [expertise], [hours] } + Assigns all listed users as mentors atomically. + + DELETE /api/v1/mentor/admin/assign// + Revokes mentor assignment for the given user. + Optional query param ?mentor_tier= scopes revocation to a single tier. + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_mentor_admin_assign_destroy + description: |- + Admin-only endpoint for bulk assigning / revoking mentor status. + + POST /api/v1/mentor/admin/assign/ + Body: { user_muids, mentor_tier, [org_id], [ig_ids], [about], [expertise], [hours] } + Assigns all listed users as mentors atomically. + + DELETE /api/v1/mentor/admin/assign// + Revokes mentor assignment for the given user. + Optional query param ?mentor_tier= scopes revocation to a single tier. + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/mentor/admin/assign/{user_muid}/: + post: + operationId: dashboard_mentor_admin_assign_create_2 + description: |- + Admin-only endpoint for bulk assigning / revoking mentor status. + + POST /api/v1/mentor/admin/assign/ + Body: { user_muids, mentor_tier, [org_id], [ig_ids], [about], [expertise], [hours] } + Assigns all listed users as mentors atomically. + + DELETE /api/v1/mentor/admin/assign// + Revokes mentor assignment for the given user. + Optional query param ?mentor_tier= scopes revocation to a single tier. + parameters: + - in: path + name: user_muid + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: dashboard_mentor_admin_assign_destroy_2 + description: |- + Admin-only endpoint for bulk assigning / revoking mentor status. + + POST /api/v1/mentor/admin/assign/ + Body: { user_muids, mentor_tier, [org_id], [ig_ids], [about], [expertise], [hours] } + Assigns all listed users as mentors atomically. + + DELETE /api/v1/mentor/admin/assign// + Revokes mentor assignment for the given user. + Optional query param ?mentor_tier= scopes revocation to a single tier. + parameters: + - in: path + name: user_muid + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/mentor/availability/: + get: + operationId: dashboard_mentor_availability_list + description: List all availability slots for the logged-in mentor. + parameters: + - in: query + name: ig_id + schema: + type: string + - in: query + name: is_active + schema: + type: boolean + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/AvailabilitySlot' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_mentor_availability_create + description: Create a new mentor availability slot. + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_mentor_availability_partial_update + description: Update an existing mentor availability slot. + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedAvailabilitySlotCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedAvailabilitySlotCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedAvailabilitySlotCreateUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_mentor_availability_destroy + description: Delete a mentor availability slot. + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/mentor/availability/{slot_id}/: + get: + operationId: dashboard_mentor_availability_list_2 + description: List all availability slots for the logged-in mentor. + parameters: + - in: query + name: ig_id + schema: + type: string + - in: query + name: is_active + schema: + type: boolean + - in: path + name: slot_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/AvailabilitySlot' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_mentor_availability_create_2 + description: Create a new mentor availability slot. + parameters: + - in: path + name: slot_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_mentor_availability_partial_update_2 + description: Update an existing mentor availability slot. + parameters: + - in: path + name: slot_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedAvailabilitySlotCreateUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedAvailabilitySlotCreateUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedAvailabilitySlotCreateUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AvailabilitySlotCreateUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_mentor_availability_destroy_2 + description: Delete a mentor availability slot. + parameters: + - in: path + name: slot_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/mentor/detail/{mentor_id}/: + get: + operationId: dashboard_mentor_detail_retrieve + description: Get details of a specific mentor by ID. + parameters: + - in: path + name: mentor_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/list/: + get: + operationId: dashboard_mentor_list_list + description: List all mentor applications with filtering. + parameters: + - in: query + name: mentor_tier + schema: + type: string + - in: query + name: status + schema: + type: string + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/MentorList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/overview/: + get: + operationId: dashboard_mentor_overview_retrieve + description: Retrieve an overview dashboard of metrics aggregated dynamically + based on the authenticated mentor's active scopes (Campus, Company, IG). + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorOverviewData' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/profile/: + get: + operationId: dashboard_mentor_profile_retrieve + description: Retrieve the profile of a verified mentor. + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_mentor_profile_partial_update + description: Update the profile of a verified mentor. + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedMentorUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedMentorUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedMentorUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/public/availability/{mentor_id}/: + get: + operationId: dashboard_mentor_public_availability_list + description: List active availability slots for a specific mentor. + parameters: + - in: path + name: mentor_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/AvailabilitySlot' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/public/profile/{mentor_id}/: + get: + operationId: dashboard_mentor_public_profile_retrieve + description: View a mentor's profile publicly. + parameters: + - in: path + name: mentor_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/register/: + post: + operationId: dashboard_mentor_register_create + description: Submit a new mentor registration. + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MentorRegister' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MentorRegister' + multipart/form-data: + schema: + $ref: '#/components/schemas/MentorRegister' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorRegister' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_mentor_register_partial_update + description: Update a pending mentor application or resubmit a rejected one. + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedMentorUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedMentorUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedMentorUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/admin/list/: + get: + operationId: dashboard_mentor_session_admin_list_list + description: Admin view to list all mentorship sessions. + parameters: + - in: query + name: ig_id + schema: + type: string + - in: query + name: status + schema: + type: string + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/SessionList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/admin/verify/{session_id}/: + patch: + operationId: dashboard_mentor_session_admin_verify_partial_update + description: Verify or reject a mentorship session. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedAdminSessionVerify' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedAdminSessionVerify' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedAdminSessionVerify' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/mentor/session/available/: + get: + operationId: dashboard_mentor_session_available_list + description: List all scheduled sessions available to the user — includes IG + sessions for their groups and company sessions for linked company orgs. + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/SessionList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/create/: + post: + operationId: dashboard_mentor_session_create_create + description: Create a new mentorship session. For IG mentors, provide an 'ig' + field. For company mentors, entity_id and session_type are auto-resolved from + the mentor's company org. To create a recurring session, set is_recurring=true, + recurrence_type ('DAILY', 'WEEKLY', 'MONTHLY'), recurrence_interval, and recurrence_end_date. + This returns the parent session while auto-spawning children. + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SessionCreate' + multipart/form-data: + schema: + $ref: '#/components/schemas/SessionCreate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/SessionCreate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/list/: + get: + operationId: dashboard_mentor_session_list_list + description: List all sessions created by the logged-in mentor, or get details + of a specific session. + parameters: + - in: query + name: status + schema: + type: string + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/SessionList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/list/{session_id}/: + get: + operationId: dashboard_mentor_session_list_list_2 + description: List all sessions created by the logged-in mentor, or get details + of a specific session. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + - in: query + name: status + schema: + type: string + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/SessionList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/participant/add/{session_id}/: + post: + operationId: dashboard_mentor_session_participant_add_create + description: Mentor view to manually add a participant to a specific session + using muid. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MentorAddParticipant' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MentorAddParticipant' + multipart/form-data: + schema: + $ref: '#/components/schemas/MentorAddParticipant' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ParticipantList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/participant/feedback/{session_id}/: + patch: + operationId: dashboard_mentor_session_participant_feedback_partial_update + description: Allows a user to submit feedback for a session they attended. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedParticipantFeedback' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedParticipantFeedback' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedParticipantFeedback' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ParticipantList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/participant/history/: + get: + operationId: dashboard_mentor_session_participant_history_list + description: List all mentorship sessions the logged-in user has joined. + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ParticipantList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/participant/list/{session_id}/: + get: + operationId: dashboard_mentor_session_participant_list_list + description: Mentor view to list all participants in a specific session. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ParticipantList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/participant/update/{link_id}/: + patch: + operationId: dashboard_mentor_session_participant_update_partial_update + description: Mentor view to update participant's attendance and progress. + parameters: + - in: path + name: link_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedParticipantUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedParticipantUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedParticipantUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ParticipantUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/participation/join/{session_id}/: + post: + operationId: dashboard_mentor_session_participation_join_create + description: Allows a MuLearn user to join a scheduled mentorship session. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ParticipantList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/student-requests/: + get: + operationId: dashboard_mentor_session_student_requests_list + description: List all pending student session requests that are within the logged-in + mentor's scope (their assigned IGs, campus, or company). + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/StudentSessionRequestList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/student-requests/{session_id}/verify/: + patch: + operationId: dashboard_mentor_session_student_requests_verify_partial_update + description: Approve or reject a student's session request. Only mentors whose + scope covers the session's entity may act on it. When approving, optional + overrides for starts_at, ends_at, mode, meeting_link, and venue allow the + mentor to adjust logistics. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedMentorStudentRequestVerify' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedMentorStudentRequestVerify' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedMentorStudentRequestVerify' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/mentor/session/student/my-requests/: + get: + operationId: dashboard_mentor_session_student_my_requests_list + description: List all session requests made by the logged-in student. + parameters: + - in: query + name: status + schema: + type: string + description: 'Filter by status: REQUESTED, PENDING_APPROVAL, REJECTED, etc.' + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/StudentSessionRequestList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/student/request/: + post: + operationId: dashboard_mentor_session_student_request_create + description: Request a mentorship session from the mentor(s) of a campus, company, + or IG. The student must be a verified member of the target entity. The session + starts in REQUESTED status and waits for a mentor to approve or reject it. + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/StudentSessionRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/StudentSessionRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/StudentSessionRequest' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/StudentSessionRequest' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/session/update/{session_id}/: + patch: + operationId: dashboard_mentor_session_update_partial_update + description: Update or cancel a mentorship session. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedSessionUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedSessionUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedSessionUpdate' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/SessionUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_mentor_session_update_destroy + description: Delete a mentorship session. + parameters: + - in: path + name: session_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/dashboard/mentor/status/: + get: + operationId: dashboard_mentor_status_retrieve + description: Check the status of a mentor registration. + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/mentor/tasks/: + get: + operationId: dashboard_mentor_tasks_retrieve + description: List all tasks submitted by the authenticated mentor (filtered + by requested_by). Supports pagination, search, and sort. + parameters: + - in: query + name: approval_status + schema: + type: string + description: 'Filter by approval_status: pending | approved | rejected' + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorTaskListResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_mentor_tasks_create + description: Create a new task for an IG the mentor belongs to. The task is + saved with approval_status='pending' and active=False until an admin approves + it. Accepts an optional skill_ids list (array of active skill UUIDs) alongside + the task fields — mirrors admin task creation behaviour. + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MentorTaskCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MentorTaskCreate' + multipart/form-data: + schema: + $ref: '#/components/schemas/MentorTaskCreate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Task submitted for approval. + /api/v1/dashboard/mentor/tasks/{task_id}/: + get: + operationId: dashboard_mentor_tasks_retrieve_2 + description: Retrieve the detail of a specific task submitted by the mentor. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/MentorTaskList' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_mentor_tasks_update + description: Update a task submitted by the mentor. Regardless of the current + approval_status, the task reverts to 'pending' and active=False after editing + (triggering re-approval). If the task was previously approved, it is deactivated + until re-approved. Accepts optional skill_ids to replace the current skill + links. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MentorTaskUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MentorTaskUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/MentorTaskUpdate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Task updated and re-submitted for approval. + delete: + operationId: dashboard_mentor_tasks_destroy + description: Delete a task submitted by the mentor. Only allowed when approval_status + is 'pending'. Approved or rejected tasks cannot be deleted. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: Task deleted successfully. + /api/v1/dashboard/mentor/tasks/ig-dropdown/: + get: + operationId: dashboard_mentor_tasks_ig_dropdown_list + description: Returns only the Interest Groups the authenticated mentor belongs + to (active MENTOR assignment). Use this list to populate the IG selector when + creating a task. + tags: + - Mentor + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/MentorIGDropdownResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/mentor/verify/{mentor_id}/: + patch: + operationId: dashboard_mentor_verify_partial_update + description: Verify or reject a mentor application. Platform admins can verify + any application; a Company's owner can verify COMPANY_MENTOR applications + scoped to their own company. + parameters: + - in: path + name: mentor_id + schema: + type: string + required: true + tags: + - Mentor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedMentorVerify' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedMentorVerify' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedMentorVerify' + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/organisation/affiliation/list/: + get: + operationId: dashboard_organisation_affiliation_list_retrieve + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/organisation/base-template/: + get: + operationId: dashboard_organisation_base_template_retrieve + description: Retrieve Organisation Base Template. + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: XLSX file download + /api/v1/dashboard/organisation/departments/: + get: + operationId: dashboard_organisation_departments_retrieve + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_organisation_departments_create + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_departments_update + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_departments_destroy + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/departments/create/: + get: + operationId: dashboard_organisation_departments_create_retrieve + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_organisation_departments_create_create + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_departments_create_update + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_departments_create_destroy + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/departments/delete/{department_id}/: + get: + operationId: dashboard_organisation_departments_delete_retrieve + parameters: + - in: path + name: department_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_organisation_departments_delete_create + parameters: + - in: path + name: department_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_departments_delete_update + parameters: + - in: path + name: department_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_departments_delete_destroy + parameters: + - in: path + name: department_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/departments/edit/{department_id}/: + get: + operationId: dashboard_organisation_departments_edit_retrieve + parameters: + - in: path + name: department_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_organisation_departments_edit_create + parameters: + - in: path + name: department_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_departments_edit_update + parameters: + - in: path + name: department_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_departments_edit_destroy + parameters: + - in: path + name: department_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/import/: + post: + operationId: dashboard_organisation_import_create + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/organisation/institutes/{org_type}/: + get: + operationId: dashboard_organisation_institutes_retrieve + description: Retrieve Institution. + parameters: + - in: path + name: org_type + schema: + type: string + required: true + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Institution' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/organisation/institutes/{org_type}/{district_id}/: + get: + operationId: dashboard_organisation_institutes_retrieve_2 + description: Retrieve Institution. + parameters: + - in: path + name: district_id + schema: + type: string + required: true + - in: path + name: org_type + schema: + type: string + required: true + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Institution' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/organisation/institutes/{org_type}/csv/: + get: + operationId: dashboard_organisation_institutes_csv_retrieve + parameters: + - in: path + name: org_type + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/organisation/institutes/create/: + post: + operationId: dashboard_organisation_institutes_create_create + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_institutes_create_update + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_institutes_create_destroy + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/institutes/delete/{org_code}/: + post: + operationId: dashboard_organisation_institutes_delete_create + parameters: + - in: path + name: org_code + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_institutes_delete_update + parameters: + - in: path + name: org_code + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_institutes_delete_destroy + parameters: + - in: path + name: org_code + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/institutes/edit/{org_code}/: + post: + operationId: dashboard_organisation_institutes_edit_create + parameters: + - in: path + name: org_code + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_institutes_edit_update + parameters: + - in: path + name: org_code + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_institutes_edit_destroy + parameters: + - in: path + name: org_code + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/institutes/info/{org_code}/: + get: + operationId: dashboard_organisation_institutes_info_retrieve + parameters: + - in: path + name: org_code + schema: + type: string + required: true + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/organisation/institutes/org/affiliation/create/: + get: + operationId: dashboard_organisation_institutes_org_affiliation_create_retrieve + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_organisation_institutes_org_affiliation_create_create + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_institutes_org_affiliation_create_update + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_institutes_org_affiliation_create_destroy + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/institutes/org/affiliation/delete/{affiliation_id}/: + get: + operationId: dashboard_organisation_institutes_org_affiliation_delete_retrieve + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_organisation_institutes_org_affiliation_delete_create + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_institutes_org_affiliation_delete_update + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_institutes_org_affiliation_delete_destroy + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/institutes/org/affiliation/edit/{affiliation_id}/: + get: + operationId: dashboard_organisation_institutes_org_affiliation_edit_retrieve + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_organisation_institutes_org_affiliation_edit_create + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_institutes_org_affiliation_edit_update + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_institutes_org_affiliation_edit_destroy + parameters: + - in: path + name: affiliation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/institutes/org/affiliation/show/: + get: + operationId: dashboard_organisation_institutes_org_affiliation_show_retrieve + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_organisation_institutes_org_affiliation_show_create + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_organisation_institutes_org_affiliation_show_update + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_organisation_institutes_org_affiliation_show_destroy + tags: + - Organisation + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/organisation/institutes/prefill/{org_code}/: + get: + operationId: dashboard_organisation_institutes_prefill_retrieve + parameters: + - in: path + name: org_code + schema: + type: string + required: true + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/organisation/karma-log/create/: + post: + operationId: dashboard_organisation_karma_log_create_create + description: Create Organization Karma Log Get Post Patch Delete. + tags: + - Organisation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationKarmaLogGetPostPatchDelete' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/OrganizationKarmaLogGetPostPatchDelete' + multipart/form-data: + schema: + $ref: '#/components/schemas/OrganizationKarmaLogGetPostPatchDelete' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/OrganizationKarmaLogGetPostPatchDelete' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/organisation/karma-type/create/: + post: + operationId: dashboard_organisation_karma_type_create_create + description: Create Organization Karma Type Get Post Patch Delete. + tags: + - Organisation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationKarmaTypeGetPostPatchDelete' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/OrganizationKarmaTypeGetPostPatchDelete' + multipart/form-data: + schema: + $ref: '#/components/schemas/OrganizationKarmaTypeGetPostPatchDelete' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/OrganizationKarmaTypeGetPostPatchDelete' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/organisation/merge_organizations/{organisation_id}/: + get: + operationId: dashboard_organisation_merge_organizations_retrieve + parameters: + - in: path + name: organisation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + patch: + operationId: dashboard_organisation_merge_organizations_partial_update + parameters: + - in: path + name: organisation_id + schema: + type: string + required: true + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/dashboard/organisation/transfer/: + post: + operationId: dashboard_organisation_transfer_create + description: Create Transfer. + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: Organisation transfer success message + /api/v1/dashboard/organisation/verify/{uorg_id}/: + post: + operationId: dashboard_organisation_verify_create + description: Create Verify Organization. + parameters: + - in: path + name: uorg_id + schema: + type: string + required: true + tags: + - Organisation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationVerify' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/OrganizationVerify' + multipart/form-data: + schema: + $ref: '#/components/schemas/OrganizationVerify' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/OrganizationVerify' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/organisation/verify/list/: + get: + operationId: dashboard_organisation_verify_list_retrieve + description: Retrieve Unverified Organizations List. + tags: + - Organisation + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UnverifiedOrganizations' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/: + get: + operationId: dashboard_profile_retrieve + description: Retrieve User Profile Edit. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserProfileEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_profile_partial_update + description: Partially update User Profile Edit. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserProfileEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_profile_destroy + description: Delete User Profile Edit. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserProfileEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/badges/{muid}: + get: + operationId: dashboard_profile_badges_retrieve + description: Retrieve Badges. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProfileBadgesResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/change-password/: + post: + operationId: dashboard_profile_change_password_create + description: Create Reset Password. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + description: Success + /api/v1/dashboard/profile/cover-pic/: + get: + operationId: dashboard_profile_cover_pic_retrieve + description: Retrieve User Profile Cover. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserProfile' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_profile_cover_pic_create + description: Create User Profile Cover. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserProfile' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_profile_cover_pic_destroy + description: Delete User Profile Cover. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserProfile' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/get-user-levels/: + get: + operationId: dashboard_profile_get_user_levels_retrieve + description: Retrieve User Levels. + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserLevel' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/get-user-levels/{muid}/: + get: + operationId: dashboard_profile_get_user_levels_retrieve_2 + description: Retrieve User Levels. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserLevel' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/ig-edit/: + get: + operationId: dashboard_profile_ig_edit_retrieve + description: Retrieve User Ig Edit. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserIgList' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_profile_ig_edit_partial_update + description: Partially update User Ig Edit. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserIgEdit' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/karma-feed/: + get: + operationId: dashboard_profile_karma_feed_retrieve + description: Retrieve Karma Feed. + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProfileKarmaFeedResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/permute/{muid}/: + get: + operationId: dashboard_profile_permute_retrieve + description: Retrieve User Permute. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserPermute' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/qrcode-get/{uuid}/: + get: + operationId: dashboard_profile_qrcode_get_retrieve + description: Retrieve Qrcode Retrieve. + parameters: + - in: path + name: uuid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserShareQrcode' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/rank/{muid}/: + get: + operationId: dashboard_profile_rank_retrieve + description: Retrieve User Rank. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserRank' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/share-user-profile/: + get: + operationId: dashboard_profile_share_user_profile_retrieve + description: Retrieve Share User Profile. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ShareUserProfileUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_profile_share_user_profile_update + description: Update Share User Profile. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ShareUserProfileUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/share-user-profile/{uuid}/: + get: + operationId: dashboard_profile_share_user_profile_retrieve_2 + description: Retrieve Share User Profile. + parameters: + - in: path + name: uuid + schema: + type: string + required: true + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ShareUserProfileUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_profile_share_user_profile_update_2 + description: Update Share User Profile. + parameters: + - in: path + name: uuid + schema: + type: string + required: true + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ShareUserProfileUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/socials/: + get: + operationId: dashboard_profile_socials_retrieve + description: Retrieve Get Socials. + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LinkSocials' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/socials/{muid}/: + get: + operationId: dashboard_profile_socials_retrieve_2 + description: Retrieve Get Socials. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LinkSocials' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/socials/edit/: + put: + operationId: dashboard_profile_socials_edit_update + description: Update Socials. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + description: Success + /api/v1/dashboard/profile/user-level-feed/: + get: + operationId: dashboard_profile_user_level_feed_retrieve + description: Retrieve User Level Feed. + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserLevel' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/user-log/: + get: + operationId: dashboard_profile_user_log_retrieve + description: Retrieve User Log. + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserLog' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/user-log/{muid}/: + get: + operationId: dashboard_profile_user_log_retrieve_2 + description: Retrieve User Log. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserLog' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/user-preferences/: + get: + operationId: dashboard_profile_user_preferences_retrieve + description: Retrieve User Preferences. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProfileUserPreferencesResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_profile_user_preferences_partial_update + description: Partially update User Preferences. + tags: + - Profile + security: + - jwtAuth: [] + - {} + responses: + '200': + description: Success + /api/v1/dashboard/profile/user-profile/: + get: + operationId: dashboard_profile_user_profile_retrieve + description: Retrieve User Profile. + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserProfile' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/user-profile/{muid}/: + get: + operationId: dashboard_profile_user_profile_retrieve_2 + description: Retrieve User Profile. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserProfile' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/profile/userterm-approved/{muid}/: + get: + operationId: dashboard_profile_userterm_approved_retrieve + description: Retrieve Userterm. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserTerm' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_profile_userterm_approved_create + description: Create Userterm. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Profile + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserTerm' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/: + get: + operationId: dashboard_projects_retrieve + description: Retrieve Projects. + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Project' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_projects_create + description: Create Projects. + tags: + - Projects + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ProjectUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/ProjectUpdate' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Project' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/{id}/: + get: + operationId: dashboard_projects_retrieve_2 + description: Retrieve Project Detail. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Project' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_projects_update + description: Update Project Detail. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Project' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_projects_destroy + description: Delete Project Detail. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Project' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/{id}/status/: + patch: + operationId: dashboard_projects_status_partial_update + description: Partially update Project Status. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Project' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/{project_id}/members/: + get: + operationId: dashboard_projects_members_retrieve + description: Retrieve Project Member. + parameters: + - in: path + name: project_id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProjectMember' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_projects_members_create + description: Create Project Member. + parameters: + - in: path + name: project_id + schema: + type: string + format: uuid + required: true + tags: + - Projects + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AddMember' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AddMember' + multipart/form-data: + schema: + $ref: '#/components/schemas/AddMember' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProjectMember' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_projects_members_destroy + description: Delete Project Member. + parameters: + - in: path + name: project_id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProjectMember' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/{project_id}/members/{id}/: + get: + operationId: dashboard_projects_members_retrieve_2 + description: Retrieve Project Member. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + - in: path + name: project_id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProjectMember' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_projects_members_create_2 + description: Create Project Member. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + - in: path + name: project_id + schema: + type: string + format: uuid + required: true + tags: + - Projects + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AddMember' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AddMember' + multipart/form-data: + schema: + $ref: '#/components/schemas/AddMember' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProjectMember' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_projects_members_destroy_2 + description: Delete Project Member. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + - in: path + name: project_id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ProjectMember' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/comment/: + post: + operationId: dashboard_projects_comment_create + description: Create Project Comment. + tags: + - Projects + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Comment' + multipart/form-data: + schema: + $ref: '#/components/schemas/Comment' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Comment' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_projects_comment_update + description: Update Project Comment. + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Comment' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_projects_comment_destroy + description: Delete Project Comment. + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Comment' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/comment/{id}/: + post: + operationId: dashboard_projects_comment_create_2 + description: Create Project Comment. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Comment' + multipart/form-data: + schema: + $ref: '#/components/schemas/Comment' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Comment' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_projects_comment_update_2 + description: Update Project Comment. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Comment' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_projects_comment_destroy_2 + description: Delete Project Comment. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Comment' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/vote/: + post: + operationId: dashboard_projects_vote_create + description: Create Project Vote. + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Vote' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_projects_vote_destroy + description: Delete Project Vote. + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Vote' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/projects/vote/{id}/: + post: + operationId: dashboard_projects_vote_create_2 + description: Create Project Vote. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Vote' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: dashboard_projects_vote_destroy_2 + description: Delete Project Vote. + parameters: + - in: path + name: id + schema: + type: string + format: uuid + required: true + tags: + - Projects + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Vote' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/referral/: + get: + operationId: dashboard_referral_retrieve + description: Retrieve Referral List. + tags: + - Referral + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ReferralList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/referral/send-referral/: + post: + operationId: dashboard_referral_send_referral_create + description: Create Referral. + tags: + - Referral + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ReferralList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/roles/: + get: + operationId: dashboard_roles_retrieve + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_roles_create + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_roles_partial_update + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_roles_destroy + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/roles/{roles_id}/: + get: + operationId: dashboard_roles_retrieve_2 + parameters: + - in: path + name: roles_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_roles_create_2 + parameters: + - in: path + name: roles_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_roles_partial_update_2 + parameters: + - in: path + name: roles_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_roles_destroy_2 + parameters: + - in: path + name: roles_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/roles/base-template/: + get: + operationId: dashboard_roles_base_template_retrieve + description: Retrieve Role Base Template. + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: XLSX file download + /api/v1/dashboard/roles/bulk-assign/: + get: + operationId: dashboard_roles_bulk_assign_retrieve + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_roles_bulk_assign_create + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_roles_bulk_assign_update + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_roles_bulk_assign_partial_update + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/roles/bulk-assign-excel/: + post: + operationId: dashboard_roles_bulk_assign_excel_create + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/roles/bulk-assign/{role_id}/: + get: + operationId: dashboard_roles_bulk_assign_retrieve_2 + parameters: + - in: path + name: role_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_roles_bulk_assign_create_2 + parameters: + - in: path + name: role_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_roles_bulk_assign_update_2 + parameters: + - in: path + name: role_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_roles_bulk_assign_partial_update_2 + parameters: + - in: path + name: role_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/roles/csv/: + get: + operationId: dashboard_roles_csv_retrieve + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/roles/user-role/: + post: + operationId: dashboard_roles_user_role_create + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_roles_user_role_destroy + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/roles/user-role/{role_id}/: + get: + operationId: dashboard_roles_user_role_retrieve + parameters: + - in: path + name: role_id + schema: + type: string + required: true + tags: + - Roles + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/skill/: + get: + operationId: dashboard_skill_retrieve + description: Retrieve Skill List. + tags: + - Skill + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Skill' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/skill/{skill_id}/: + get: + operationId: dashboard_skill_retrieve_2 + description: Retrieve Skill Detail. + parameters: + - in: path + name: skill_id + schema: + type: string + required: true + tags: + - Skill + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Skill' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: dashboard_skill_update + description: Get, update, or delete a specific skill + parameters: + - in: path + name: skill_id + schema: + type: string + required: true + tags: + - Skill + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_skill_destroy + description: Get, update, or delete a specific skill + parameters: + - in: path + name: skill_id + schema: + type: string + required: true + tags: + - Skill + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/skill/{skill_id}/tasks/: + get: + operationId: dashboard_skill_tasks_retrieve + description: Retrieve Skill Tasks. + parameters: + - in: path + name: skill_id + schema: + type: string + required: true + tags: + - Skill + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Skill' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/skill/create/: + post: + operationId: dashboard_skill_create_create + description: Create a new skill + tags: + - Skill + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/skill/dropdown/: + get: + operationId: dashboard_skill_dropdown_retrieve + description: Retrieve Skill Dropdown. + tags: + - Skill + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/SkillDropdown' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/task/: + get: + operationId: dashboard_task_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_task_create + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/{task_id}/: + get: + operationId: dashboard_task_retrieve_2 + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_task_update + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_task_destroy + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/task/{task_id}/review/: + get: + operationId: dashboard_task_review_retrieve + description: |- + GET /dashboard/task/pending/ — list all tasks awaiting admin review + PATCH /dashboard/task//approve/ — approve a pending task (goes live) + PATCH /dashboard/task//reject/ — reject with a reason + + All actions require the Admin role. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_task_review_partial_update + description: |- + GET /dashboard/task/pending/ — list all tasks awaiting admin review + PATCH /dashboard/task//approve/ — approve a pending task (goes live) + PATCH /dashboard/task//reject/ — reject with a reason + + All actions require the Admin role. + parameters: + - in: path + name: task_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/base-template/: + get: + operationId: dashboard_task_base_template_retrieve + description: Retrieve Task Base Template. + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: Excel template file download (task_base_template.xlsx) + /api/v1/dashboard/task/channel/: + get: + operationId: dashboard_task_channel_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/csv/: + get: + operationId: dashboard_task_csv_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/events/: + get: + operationId: dashboard_task_events_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/ig/: + get: + operationId: dashboard_task_ig_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/import/: + post: + operationId: dashboard_task_import_create + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/level/: + get: + operationId: dashboard_task_level_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/list/: + get: + operationId: dashboard_task_list_retrieve + description: Retrieve Task Public List. + parameters: + - in: query + name: task_source + schema: + type: string + enum: + - campus_mentor + - company + - ig_mentor + - platform + description: 'Filter tasks by creator type: ''company'' = tasks submitted + by a verified company user, ''ig_mentor'' = tasks submitted by an approved + IG mentor, ''campus_mentor'' = tasks submitted by an approved campus mentor, + ''platform'' = tasks created by platform admins. Eligibility (IG/campus + membership) is enforced by the existing visibility rules.' + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/TaskPublicListResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/task/list-task-type/: + get: + operationId: dashboard_task_list_task_type_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_task_list_task_type_create + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_task_list_task_type_update + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_task_list_task_type_destroy + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/task/organization/: + get: + operationId: dashboard_task_organization_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/pending/: + get: + operationId: dashboard_task_pending_retrieve + description: |- + GET /dashboard/task/pending/ — list all tasks awaiting admin review + PATCH /dashboard/task//approve/ — approve a pending task (goes live) + PATCH /dashboard/task//reject/ — reject with a reason + + All actions require the Admin role. + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_task_pending_partial_update + description: |- + GET /dashboard/task/pending/ — list all tasks awaiting admin review + PATCH /dashboard/task//approve/ — approve a pending task (goes live) + PATCH /dashboard/task//reject/ — reject with a reason + + All actions require the Admin role. + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/task/task-type/{task_type_id}/: + get: + operationId: dashboard_task_task_type_retrieve + parameters: + - in: path + name: task_type_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: dashboard_task_task_type_create + parameters: + - in: path + name: task_type_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: dashboard_task_task_type_update + parameters: + - in: path + name: task_type_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_task_task_type_destroy + parameters: + - in: path + name: task_type_id + schema: + type: string + required: true + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/task/task-types/: + get: + operationId: dashboard_task_task_types_retrieve + tags: + - Task + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/user/: + get: + operationId: dashboard_user_retrieve + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/user/{user_id}/: + get: + operationId: dashboard_user_retrieve_2 + parameters: + - in: path + name: user_id + schema: + type: string + required: true + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_user_partial_update + parameters: + - in: path + name: user_id + schema: + type: string + required: true + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_user_destroy + parameters: + - in: path + name: user_id + schema: + type: string + required: true + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/user/csv/: + get: + operationId: dashboard_user_csv_retrieve + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/user/forgot-password/: + post: + operationId: dashboard_user_forgot_password_create + description: Create Forgot Password. + tags: + - User + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: Forgot-password email sent + /api/v1/dashboard/user/info/: + get: + operationId: dashboard_user_info_retrieve + description: Retrieve User Info. + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/User' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/user/organization/: + get: + operationId: dashboard_user_organization_retrieve + description: Retrieve User Add Org. + tags: + - User + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/GetUserLink' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_user_organization_create + description: Create User Add Org. + tags: + - User + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserOrgLink' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UserOrgLink' + multipart/form-data: + schema: + $ref: '#/components/schemas/UserOrgLink' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserOrgLink' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/user/organization/list/: + get: + operationId: dashboard_user_organization_list_retrieve + description: Retrieve User Add Org. + tags: + - User + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/GetUserLink' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: dashboard_user_organization_list_create + description: Create User Add Org. + tags: + - User + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserOrgLink' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UserOrgLink' + multipart/form-data: + schema: + $ref: '#/components/schemas/UserOrgLink' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserOrgLink' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/user/preferences/: + get: + operationId: dashboard_user_preferences_retrieve + description: Retrieve User Preferences. + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/User' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_user_preferences_partial_update + description: Partially update User Preferences. + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/User' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/user/profile/update/: + post: + operationId: dashboard_user_profile_update_create + description: Create User Profile Picture. + tags: + - User + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/User' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: dashboard_user_profile_update_partial_update + description: Partially update User Profile Picture. + tags: + - User + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/User' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/user/reset-password/{token}/: + post: + operationId: dashboard_user_reset_password_create + description: Create Reset Password Confirm. + parameters: + - in: path + name: token + schema: + type: string + required: true + tags: + - User + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: Password reset confirmation + /api/v1/dashboard/user/reset-password/verify-token/{token}/: + post: + operationId: dashboard_user_reset_password_verify_token_create + description: Create Reset Password Verify Token. + parameters: + - in: path + name: token + schema: + type: string + required: true + tags: + - User + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserResetPasswordVerifyTokenResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/user/search/: + get: + operationId: dashboard_user_search_retrieve + description: Retrieve User Search. + tags: + - User + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserBasicDetails' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/dashboard/user/verification/: + get: + operationId: dashboard_user_verification_retrieve + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_user_verification_partial_update + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_user_verification_destroy + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/user/verification/{link_id}/: + get: + operationId: dashboard_user_verification_retrieve_2 + parameters: + - in: path + name: link_id + schema: + type: string + required: true + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + patch: + operationId: dashboard_user_verification_partial_update_2 + parameters: + - in: path + name: link_id + schema: + type: string + required: true + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: dashboard_user_verification_destroy_2 + parameters: + - in: path + name: link_id + schema: + type: string + required: true + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/dashboard/user/verification/csv/: + get: + operationId: dashboard_user_verification_csv_retrieve + tags: + - User + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/zonal/college-details/: + get: + operationId: dashboard_zonal_college_details_retrieve + tags: + - Zonal + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/zonal/college-details/csv/: + get: + operationId: dashboard_zonal_college_details_csv_retrieve + tags: + - Zonal + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/zonal/student-details/: + get: + operationId: dashboard_zonal_student_details_retrieve + tags: + - Zonal + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/zonal/student-details/csv/: + get: + operationId: dashboard_zonal_student_details_csv_retrieve + tags: + - Zonal + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/zonal/student-level/: + get: + operationId: dashboard_zonal_student_level_retrieve + tags: + - Zonal + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/zonal/top-districts/: + get: + operationId: dashboard_zonal_top_districts_retrieve + tags: + - Zonal + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/dashboard/zonal/zonal-details/: + get: + operationId: dashboard_zonal_zonal_details_retrieve + tags: + - Zonal + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/donate/bank-transfer/: + post: + operationId: donate_bank_transfer_create + description: Create Bank Transfer. + tags: + - Donate + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BankTransfer' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/BankTransfer' + multipart/form-data: + schema: + $ref: '#/components/schemas/BankTransfer' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/BankTransfer' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/donate/order/: + post: + operationId: donate_order_create + description: Create Razor Pay Order. + tags: + - Donate + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Order' + multipart/form-data: + schema: + $ref: '#/components/schemas/Order' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Order' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/donate/subscription/create/: + post: + operationId: donate_subscription_create_create + description: Create Razor Pay Subscription. + tags: + - Donate + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Subscription' + multipart/form-data: + schema: + $ref: '#/components/schemas/Subscription' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Subscription' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/donate/subscription/verify/: + post: + operationId: donate_subscription_verify_create + description: Create Razor Pay Subscription Verification. + tags: + - Donate + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Donation' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Donation' + multipart/form-data: + schema: + $ref: '#/components/schemas/Donation' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Donation' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/donate/verify/: + post: + operationId: donate_verify_create + description: Create Razor Pay Verification. + tags: + - Donate + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Donation' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Donation' + multipart/form-data: + schema: + $ref: '#/components/schemas/Donation' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Donation' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/hackathon/add-organiser/{hackathon_id}/: + get: + operationId: hackathon_add_organiser_retrieve + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_add_organiser_create + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_add_organiser_destroy + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/create-hackathon/: + get: + operationId: hackathon_create_hackathon_retrieve + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_create_hackathon_create + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: hackathon_create_hackathon_update + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_create_hackathon_destroy + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/delete-hackathon/{hackathon_id}/: + get: + operationId: hackathon_delete_hackathon_retrieve + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_delete_hackathon_create + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: hackathon_delete_hackathon_update + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_delete_hackathon_destroy + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/delete-organiser/{organiser_link_id}/: + get: + operationId: hackathon_delete_organiser_retrieve + parameters: + - in: path + name: organiser_link_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_delete_organiser_create + parameters: + - in: path + name: organiser_link_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_delete_organiser_destroy + parameters: + - in: path + name: organiser_link_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/edit-hackathon/{hackathon_id}/: + get: + operationId: hackathon_edit_hackathon_retrieve + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_edit_hackathon_create + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: hackathon_edit_hackathon_update + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_edit_hackathon_destroy + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/info/{hackathon_id}/: + get: + operationId: hackathon_info_retrieve + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/hackathon/list-applicants/: + get: + operationId: hackathon_list_applicants_retrieve + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/hackathon/list-applicants/{hackathon_id}/: + get: + operationId: hackathon_list_applicants_retrieve_2 + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/hackathon/list-default-form-fields/: + get: + operationId: hackathon_list_default_form_fields_retrieve + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/hackathon/list-districts/: + get: + operationId: hackathon_list_districts_retrieve + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/hackathon/list-form/{hackathon_id}/: + get: + operationId: hackathon_list_form_retrieve + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/hackathon/list-hackathons/: + get: + operationId: hackathon_list_hackathons_retrieve + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_list_hackathons_create + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: hackathon_list_hackathons_update + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_list_hackathons_destroy + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/list-hackathons/{hackathon_id}/: + get: + operationId: hackathon_list_hackathons_retrieve_2 + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_list_hackathons_create_2 + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: hackathon_list_hackathons_update_2 + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_list_hackathons_destroy_2 + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/list-hackathons/upcoming/: + get: + operationId: hackathon_list_hackathons_upcoming_retrieve + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_list_hackathons_upcoming_create + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: hackathon_list_hackathons_upcoming_update + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_list_hackathons_upcoming_destroy + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/list-organisations/: + get: + operationId: hackathon_list_organisations_retrieve + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/hackathon/list-organiser-hackathons/{hackathon_id}/: + get: + operationId: hackathon_list_organiser_hackathons_retrieve + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: hackathon_list_organiser_hackathons_create + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: hackathon_list_organiser_hackathons_destroy + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/hackathon/publish-hackathon/{hackathon_id}/: + put: + operationId: hackathon_publish_hackathon_update + parameters: + - in: path + name: hackathon_id + schema: + type: string + required: true + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/hackathon/submit-hackathon/: + post: + operationId: hackathon_submit_hackathon_create + tags: + - Hackathon + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/integrations/kkem/authorization/: + post: + operationId: integrations_kkem_authorization_create + description: Create K K E M Authorization. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: Authorization created; confirmation email sent. + patch: + operationId: integrations_kkem_authorization_partial_update + description: Partially update K K E M Authorization. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/KkemAuthorizationPatchResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/kkem/authorization/{token}/: + post: + operationId: integrations_kkem_authorization_create_2 + description: Create K K E M Authorization. + parameters: + - in: path + name: token + schema: + type: string + required: true + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: Authorization created; confirmation email sent. + patch: + operationId: integrations_kkem_authorization_partial_update_2 + description: Partially update K K E M Authorization. + parameters: + - in: path + name: token + schema: + type: string + required: true + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/KkemAuthorizationPatchResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/kkem/hackathon-stats/: + get: + operationId: integrations_kkem_hackathon_stats_retrieve + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/integrations/kkem/login/: + post: + operationId: integrations_kkem_login_create + description: Create K K E M Integration Login. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/KkemIntegrationLoginResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/kkem/user/{encrypted_data}/: + get: + operationId: integrations_kkem_user_retrieve + description: Retrieve K K E Mdetails Fetch. + parameters: + - in: path + name: encrypted_data + schema: + type: string + required: true + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: KKEM jobseeker details returned from the KKEM API. + /api/v1/integrations/kkem/user/status/{encrypted_data}/: + get: + operationId: integrations_kkem_user_status_retrieve + description: Retrieve K K E M User Status. + parameters: + - in: path + name: encrypted_data + schema: + type: string + required: true + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/KKEMUser' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/kkem/users/: + get: + operationId: integrations_kkem_users_retrieve + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/integrations/kkem/users/{muid}/: + get: + operationId: integrations_kkem_users_retrieve_2 + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/integrations/mufifa/verify-task/: + get: + operationId: integrations_mufifa_verify_task_retrieve + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/integrations/qseverse/connected-users/: + get: + operationId: integrations_qseverse_connected_users_list + description: Retrieve Get All Connected Users. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/QseverseConnectedUserItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/qseverse/connected-users/search: + get: + operationId: integrations_qseverse_connected_users_search_retrieve + description: Retrieve Get Connected User. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/QseverseConnectedUserDidsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/qseverse/issue-vc/: + post: + operationId: integrations_qseverse_issue_vc_create + description: Create Issue Verifiable Credential. + tags: + - Integrations + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/IssueVC' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/IssueVC' + multipart/form-data: + schema: + $ref: '#/components/schemas/IssueVC' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/IssueVC' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/qseverse/qs-credentials/: + get: + operationId: integrations_qseverse_qs_credentials_list + description: Retrieve Get Q S Credentials. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/QseverseCredentialItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/wadhwani/auth-token/: + post: + operationId: integrations_wadhwani_auth_token_create + description: Create Wadhwani Auth Token. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WadhwaniAuthTokenResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/wadhwani/course-details/: + post: + operationId: integrations_wadhwani_course_details_create + description: Create Wadhwani Course Details. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WadhwaniCourseDetailsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/wadhwani/course-enroll-status/: + post: + operationId: integrations_wadhwani_course_enroll_status_create + description: Create Wadhwani Course Enroll Status. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WadhwaniCourseEnrollStatusResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/wadhwani/course-quiz-data/: + post: + operationId: integrations_wadhwani_course_quiz_data_create + description: Create Wadhwani Course Quiz Data. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WadhwaniCourseQuizDataResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/integrations/wadhwani/user-login/: + post: + operationId: integrations_wadhwani_user_login_create + description: Create Wadhwani User Login. + tags: + - Integrations + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WadhwaniUserLoginResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/accepted-students/: + get: + operationId: launchpad_accepted_students_retrieve + description: Retrieve Accepted Students. + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadAcceptedStudentsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/accepted-students/{job_id}/: + get: + operationId: launchpad_accepted_students_retrieve_2 + description: Retrieve Accepted Students. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadAcceptedStudentsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/add-job/: + post: + operationId: launchpad_add_job_create + description: Create Add Job. + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LaunchpadJobs' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LaunchpadJobs' + multipart/form-data: + schema: + $ref: '#/components/schemas/LaunchpadJobs' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadJobs' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/application-final-decision/: + post: + operationId: launchpad_application_final_decision_create + description: Create Application Final Decision. + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadApplicationDecisionResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/bulk-user-college-link/: + post: + operationId: launchpad_bulk_user_college_link_create + description: Create Bulk Launchpad User. + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LaunchpadUser' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LaunchpadUser' + multipart/form-data: + schema: + $ref: '#/components/schemas/LaunchpadUser' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUser' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/change-password/: + post: + operationId: launchpad_change_password_create + description: Create Change Password. + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChangePassword' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ChangePassword' + multipart/form-data: + schema: + $ref: '#/components/schemas/ChangePassword' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChangePassword' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/college-data/: + get: + operationId: launchpad_college_data_retrieve + description: Retrieve College Data. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CollegeData' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/company-info/: + post: + operationId: launchpad_company_info_create + description: Create Get Company Info. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadCompanyInfoResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/company-list/: + get: + operationId: launchpad_company_list_retrieve + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/launchpad/company-list-verified/: + get: + operationId: launchpad_company_list_verified_retrieve + description: Retrieve Company List Verified. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadCompanyPublic' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/company-verify/: + post: + operationId: launchpad_company_verify_create + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/launchpad/delete-company/: + patch: + operationId: launchpad_delete_company_partial_update + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/launchpad/forgot-password/: + post: + operationId: launchpad_forgot_password_create + description: Create Forgot Password. + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ForgotPassword' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ForgotPassword' + multipart/form-data: + schema: + $ref: '#/components/schemas/ForgotPassword' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ForgotPassword' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/get-user-levels/{launchpad_id}/: + get: + operationId: launchpad_get_user_levels_retrieve + parameters: + - in: path + name: launchpad_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/launchpad/hire-requests/: + get: + operationId: launchpad_hire_requests_retrieve + description: Retrieve Hire Requests. + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadHireRequestsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/ig-leaderboard/: + get: + operationId: launchpad_ig_leaderboard_retrieve + description: Retrieve I G Leaderboard. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadIGLeaderboardResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/job/{job_id}/: + get: + operationId: launchpad_job_retrieve + description: Retrieve Job. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadJobUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: launchpad_job_update + description: Update Job. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadJobUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: launchpad_job_destroy + description: Delete Job. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadJobUpdate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/launchpad-details/: + get: + operationId: launchpad_launchpad_details_retrieve + description: Retrieve Launchpad Details Count. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadDetailsCountResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/leaderboard/: + get: + operationId: launchpad_leaderboard_retrieve + description: Retrieve Leaderboard. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadLeaderBoard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/list-jobs/: + get: + operationId: launchpad_list_jobs_retrieve + description: Retrieve List Jobs. + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadListJobsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/list-launchpad-students/{job_id}/: + get: + operationId: launchpad_list_launchpad_students_retrieve + description: Retrieve List Launchpad Students. + parameters: + - in: path + name: job_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/EligibleStudent' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/list-participants/: + get: + operationId: launchpad_list_participants_retrieve + description: Retrieve List Participants. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadParticipants' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/list-participants-admin/: + get: + operationId: launchpad_list_participants_admin_retrieve + description: Retrieve Launch Pad List Admin. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadLeaderBoard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/login-company/: + post: + operationId: launchpad_login_company_create + description: Create Login Company. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadLoginCompanyResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/login-recruiter/: + post: + operationId: launchpad_login_recruiter_create + description: Create Login Recruiter. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadLoginRecruiterResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/recruiter-info/: + post: + operationId: launchpad_recruiter_info_create + description: Create Get Recruiter Info. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadRecruiterInfoResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/refresh-token/: + post: + operationId: launchpad_refresh_token_create + description: Create Refresh Token. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadRefreshTokenResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/register-company/: + post: + operationId: launchpad_register_company_create + description: Create Register Company. + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LaunchpadCompanies' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LaunchpadCompanies' + multipart/form-data: + schema: + $ref: '#/components/schemas/LaunchpadCompanies' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadCompanies' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/register-recruiter/: + post: + operationId: launchpad_register_recruiter_create + description: Create Register Recruiter. + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LaunchpadRecruiter' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LaunchpadRecruiter' + multipart/form-data: + schema: + $ref: '#/components/schemas/LaunchpadRecruiter' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadRecruiter' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/reset-password/: + post: + operationId: launchpad_reset_password_create + description: Create Reset Password. + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ResetPassword' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ResetPassword' + multipart/form-data: + schema: + $ref: '#/components/schemas/ResetPassword' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ResetPassword' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/schedule-interview/: + post: + operationId: launchpad_schedule_interview_create + description: Create Schedule Interview. + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadScheduleInterviewResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/send-job-invitations/: + post: + operationId: launchpad_send_job_invitations_create + description: Create Send Job Invitations. + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadSendInvitationsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/socials/{launchpad_id}/: + get: + operationId: launchpad_socials_retrieve + parameters: + - in: path + name: launchpad_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/launchpad/student/apply-to-job/: + post: + operationId: launchpad_student_apply_to_job_create + description: Create Student Apply To Job. + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadStudentApplyResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/student/job-invitations/: + get: + operationId: launchpad_student_job_invitations_retrieve + description: Retrieve Student Job Invitations. + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadStudentInvitationsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/task-completed-leaderboard/: + get: + operationId: launchpad_task_completed_leaderboard_retrieve + description: Retrieve Task Completed Leaderboard. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/TaskCompletedLeaderBoard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/user-college-data/: + get: + operationId: launchpad_user_college_data_retrieve + description: Retrieve User Based College Data. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CollegeData' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/user-college-link/: + get: + operationId: launchpad_user_college_link_retrieve + description: Retrieve Launch Pad User. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUserList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: launchpad_user_college_link_create + description: Create Launch Pad User. + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LaunchpadUser' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LaunchpadUser' + multipart/form-data: + schema: + $ref: '#/components/schemas/LaunchpadUser' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUser' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: launchpad_user_college_link_update + description: Update Launch Pad User. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUpdateUser' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/user-college-link-public/{email}: + get: + operationId: launchpad_user_college_link_public_retrieve + description: Retrieve Launch Pad User Public. + parameters: + - in: path + name: email + schema: + type: string + required: true + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUserList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/user-college-link/{email}: + get: + operationId: launchpad_user_college_link_retrieve_2 + description: Retrieve Launch Pad User. + parameters: + - in: path + name: email + schema: + type: string + required: true + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUserList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: launchpad_user_college_link_create_2 + description: Create Launch Pad User. + parameters: + - in: path + name: email + schema: + type: string + required: true + tags: + - Launchpad + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/LaunchpadUser' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LaunchpadUser' + multipart/form-data: + schema: + $ref: '#/components/schemas/LaunchpadUser' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUser' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: launchpad_user_college_link_update_2 + description: Update Launch Pad User. + parameters: + - in: path + name: email + schema: + type: string + required: true + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUpdateUser' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/user-details/{launchpad_id}/: + get: + operationId: launchpad_user_details_retrieve + parameters: + - in: path + name: launchpad_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/launchpad/user-log/{launchpad_id}/: + get: + operationId: launchpad_user_log_retrieve + parameters: + - in: path + name: launchpad_id + schema: + type: string + required: true + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/launchpad/user-profile/: + get: + operationId: launchpad_user_profile_retrieve + description: Retrieve User Profile. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUserList' + required: + - hasError + - statusCode + - message + - response + description: '' + put: + operationId: launchpad_user_profile_update + description: Update User Profile. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadUserList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/verify-reset-token/: + post: + operationId: launchpad_verify_reset_token_create + description: Create Verify Reset Token. + tags: + - Launchpad + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LaunchpadVerifyResetTokenResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/launchpad/verify-task/: + post: + operationId: launchpad_verify_task_create + tags: + - Launchpad + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/leaderboard/campus-mentor/{campus_id}/: + get: + operationId: leaderboard_campus_mentor_list + description: Retrieve the mentor leaderboard for a specific campus. Mentors + are ranked primarily by the number of COMPLETED campus sessions, with total + karma as a tiebreaker. + parameters: + - in: path + name: campus_id + schema: + type: string + required: true + tags: + - Leaderboard + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/CampusMentorLeaderboard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/leaderboard/college/: + get: + operationId: leaderboard_college_list + description: Retrieve College Leaderboard. + tags: + - Leaderboard + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/LeaderboardCollegeItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/leaderboard/college-monthly/: + get: + operationId: leaderboard_college_monthly_list + description: Retrieve College Monthly Leaderboard. + tags: + - Leaderboard + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/LeaderboardCollegeMonthlyItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/leaderboard/ig-mentor/{ig_id}/: + get: + operationId: leaderboard_ig_mentor_list + description: Retrieve the mentor leaderboard for a specific Interest Group. + Mentors are ranked primarily by the number of COMPLETED sessions in that IG, + with total karma as a tiebreaker. + parameters: + - in: path + name: ig_id + schema: + type: string + required: true + tags: + - Leaderboard + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/IGMentorLeaderboard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/leaderboard/students/: + get: + operationId: leaderboard_students_retrieve + description: Retrieve Students Leaderboard. + tags: + - Leaderboard + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/StudentLeaderboard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/leaderboard/students-monthly/: + get: + operationId: leaderboard_students_monthly_list + description: Retrieve Students Monthly Leaderboard. + tags: + - Leaderboard + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/LeaderboardStudentsMonthlyItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/leaderboard/wadhwani-college/: + get: + operationId: leaderboard_wadhwani_college_retrieve + description: Retrieve Wadhwani College Leaderboard. + tags: + - Leaderboard + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WadhwaniCollegeLeaderboard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/leaderboard/wadhwani-zonal/: + get: + operationId: leaderboard_wadhwani_zonal_retrieve + description: Retrieve Wadhwani Zonal Leaderboard. + tags: + - Leaderboard + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/WadhwaniZoneLeaderboard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/: + get: + operationId: muComics_chapters_list + description: List all active (non-deleted) chapters for a comic. Supports status + filter and search by title. + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ChapterList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: muComics_chapters_create + description: Create a new chapter inside a comic. Only creator or editors of + the comic can create. + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChapterWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ChapterWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/ChapterWrite' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChapterDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/{chapter_id}/: + get: + operationId: muComics_chapters_retrieve + description: Retrieve full detail of a chapter including pages. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChapterDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: muComics_chapters_partial_update + description: Update chapter details. Requires comic creator or editor permissions. + Archived chapters cannot be edited. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedChapterWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedChapterWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedChapterWrite' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChapterDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: muComics_chapters_destroy + description: Soft delete a chapter. Only the comic creator can delete a chapter. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChapterDeleteResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/{chapter_id}/archive/: + post: + operationId: muComics_chapters_archive_create + description: Archive a chapter. Comic creator only. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChapterArchiveResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/{chapter_id}/pages/: + get: + operationId: muComics_chapters_pages_list + description: List all active pages of a chapter, ordered by page number. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ChapterPage' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: muComics_chapters_pages_create + description: Add a single page to a chapter. Comic creators or editors only. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChapterPage' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ChapterPage' + multipart/form-data: + schema: + $ref: '#/components/schemas/ChapterPage' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChapterPage' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/{chapter_id}/pages/register/: + post: + operationId: muComics_chapters_pages_register_create + description: Register multiple uploaded local media paths as sequential pages + inside a chapter. Creator/editors only. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterImagesRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RegisterImagesRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/RegisterImagesRequest' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ChapterPage' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/{chapter_id}/pages/reorder/: + post: + operationId: muComics_chapters_pages_reorder_create + description: Bulk reorder page numbers in a chapter. Comic creators or editors + only. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PageReorderRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PageReorderRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PageReorderRequest' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/PageReorderResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/{chapter_id}/publish/: + post: + operationId: muComics_chapters_publish_create + description: Publish a chapter. Comic creator only. + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChapterPublishResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/pages/{page_id}/: + patch: + operationId: muComics_chapters_pages_partial_update + description: Update page information (e.g. page_number or image_key). Comic + creators or editors only. + parameters: + - in: path + name: page_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedChapterPage' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedChapterPage' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedChapterPage' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ChapterPage' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: muComics_chapters_pages_destroy + description: Delete a page (soft delete). Comic creators or editors only. + parameters: + - in: path + name: page_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/PageDeleteResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/chapters/upload-url/: + post: + operationId: muComics_chapters_upload_url_create + description: Upload a media file (Image or PDF) to local storage. + tags: + - Mucomics + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/ChapterUploadRequest' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UploadURLResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/: + get: + operationId: muComics_comics_list + description: List all active (non-deleted) comics. Supports search by title, + status filter, and sort by created_at or title. + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ComicListItem' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: muComics_comics_create + description: Create a new comic. Any authenticated user may create a comic. + Returns the full comic detail on success. + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ComicWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ComicWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/ComicWrite' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ComicDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/{comic_id}/: + get: + operationId: muComics_comics_retrieve + description: Retrieve full detail for a single active comic, including contributors + and genres. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ComicDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: muComics_comics_partial_update + description: Partially update a comic's title, description, or cover image. + Only the creator or an assigned editor contributor may edit. Archived comics + cannot be edited. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedComicWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedComicWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedComicWrite' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ComicDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: muComics_comics_destroy + description: Soft-delete a comic (sets deleted_at). Only the original creator + may delete. The comic is hidden from all list/detail responses after deletion. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ComicDeleteResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/{comic_id}/archive/: + post: + operationId: muComics_comics_archive_create + description: Archive a draft or published comic (draft/published → archived). + Only the creator may archive. Already-archived comics are rejected. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ComicArchiveResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/{comic_id}/contributors/: + get: + operationId: muComics_comics_contributors_list + description: List all contributors for a comic. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/ContributorList' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: muComics_comics_contributors_create + description: Add a contributor to a comic by muid and role. Only the comic creator + or an Admin may call this. The CREATOR role cannot be assigned via this endpoint. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ContributorWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ContributorWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/ContributorWrite' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ContributorAddResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/{comic_id}/contributors/{contributor_id}/: + patch: + operationId: muComics_comics_contributors_partial_update + description: Update a contributor's role. Only the comic creator or an Admin + may call this. The CREATOR role cannot be assigned. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + - in: path + name: contributor_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedContributorRoleUpdate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedContributorRoleUpdate' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedContributorRoleUpdate' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ContributorUpdateResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: muComics_comics_contributors_destroy + description: Remove a contributor from a comic. Only the comic creator or an + Admin may call this. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + - in: path + name: contributor_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ContributorRemoveResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/{comic_id}/genres/: + post: + operationId: muComics_comics_genres_create_2 + description: Assign a genre to a comic. Only the creator, an assigned editor, + or an admin might perform this action. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ComicGenreAssign' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ComicGenreAssign' + multipart/form-data: + schema: + $ref: '#/components/schemas/ComicGenreAssign' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ComicGenreAssignResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/{comic_id}/genres/{link_id}/: + delete: + operationId: muComics_comics_genres_destroy_2 + description: Remove a genre from a comic. Only the creator, an assigned editor, + or an admin might perform this action. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + - in: path + name: link_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ComicGenreRemoveResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/{comic_id}/publish/: + post: + operationId: muComics_comics_publish_create + description: Publish a draft comic (draft → published). Only the creator may + publish. Requires title and description to be present. Archived comics cannot + be re-published. + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ComicPublishResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/genres/: + get: + operationId: muComics_comics_genres_list + description: List genres. Non-admins see only active genres. Admins can filter + with ?is_active=true|false. Supports ?search= (by name) and ?sort=name|created_at. + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/GenreRead' + required: + - hasError + - statusCode + - message + - response + description: '' + post: + operationId: muComics_comics_genres_create + description: Create a new genre. is_active defaults to true. Admin only. + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GenreWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/GenreWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/GenreWrite' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/GenreRead' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/genres/{genre_id}/: + get: + operationId: muComics_comics_genres_retrieve + description: Retrieve a genre. Non-admins only see active genres (inactive → + 404). Admins can retrieve any genre regardless of is_active status. + parameters: + - in: path + name: genre_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/GenreRead' + required: + - hasError + - statusCode + - message + - response + description: '' + patch: + operationId: muComics_comics_genres_partial_update + description: Update a genre's name (slug is regenerated automatically). Admin + only. + parameters: + - in: path + name: genre_id + schema: + type: string + required: true + tags: + - Mucomics + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedGenreWrite' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedGenreWrite' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedGenreWrite' + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/GenreRead' + required: + - hasError + - statusCode + - message + - response + description: '' + delete: + operationId: muComics_comics_genres_destroy + description: Soft-deactivate a genre (sets is_active = False). The genre row + and all comic_genre_link rows are preserved. Admin only. + parameters: + - in: path + name: genre_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/GenreDeactivateResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comics/genres/{genre_id}/reinstate/: + post: + operationId: muComics_comics_genres_reinstate_create + description: Reinstate (reactivate) a deactivated genre. Sets is_active = True. + Admin only. + parameters: + - in: path + name: genre_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/GenreReinstateResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/muComics/comments/{comment_id}/: + patch: + operationId: muComics_comments_partial_update + description: |- + PATCH /comments// — edit own comment + DELETE /comments// — soft-delete own comment + parameters: + - in: path + name: comment_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + delete: + operationId: muComics_comments_destroy + description: |- + PATCH /comments// — edit own comment + DELETE /comments// — soft-delete own comment + parameters: + - in: path + name: comment_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/muComics/comments/admin/: + get: + operationId: muComics_comments_admin_retrieve + description: GET /admin/comments/ — list all comments for moderation (flat list) + tags: + - Mucomics + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/muComics/comments/admin/{comment_id}/: + delete: + operationId: muComics_comments_admin_destroy + description: DELETE /admin/comments// — soft-delete any comment + (moderator) + parameters: + - in: path + name: comment_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/muComics/comments/chapter/{chapter_id}/create/: + post: + operationId: muComics_comments_chapter_create_create + description: POST /comments/chapter//create/ — create chapter comment + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/muComics/comments/chapter/{chapter_id}/list/: + get: + operationId: muComics_comments_chapter_list_retrieve + description: GET /comments/chapter//list/ — list chapter comments + parameters: + - in: path + name: chapter_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/muComics/comments/comic/{comic_id}/create/: + post: + operationId: muComics_comments_comic_create_create + description: POST /comments/comic//create/ — create a comment (top-level + or reply) + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + description: No response body + /api/v1/muComics/comments/comic/{comic_id}/list/: + get: + operationId: muComics_comments_comic_list_retrieve + description: GET /comments/comic//list/ — list top-level comments + with replies + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/muComics/reader/comics/{comic_id}/bookmarks/: + post: + operationId: muComics_reader_comics_bookmarks_create + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: muComics_reader_comics_bookmarks_destroy + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/muComics/reader/comics/{comic_id}/interaction-status/: + get: + operationId: muComics_reader_comics_interaction_status_retrieve + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/muComics/reader/comics/{comic_id}/likes/: + post: + operationId: muComics_reader_comics_likes_create + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: muComics_reader_comics_likes_destroy + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/muComics/reader/comics/{comic_id}/progress/: + get: + operationId: muComics_reader_comics_progress_retrieve + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: muComics_reader_comics_progress_update + parameters: + - in: path + name: comic_id + schema: + type: string + required: true + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/muComics/reader/me/: + get: + operationId: muComics_reader_me_retrieve + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/muComics/reader/me/bookmarks/: + get: + operationId: muComics_reader_me_bookmarks_retrieve + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/muComics/reader/me/progress/: + get: + operationId: muComics_reader_me_progress_retrieve + tags: + - Mucomics + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/notification/broadcast/create/: + post: + operationId: notification_broadcast_create_create + description: Admin only. Create a new global broadcast announcement. + tags: + - Notification + security: + - jwtAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/BroadcastNotificationAdmin' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/notification/broadcast/delete/all/: + delete: + operationId: notification_broadcast_delete_all_destroy + description: Delete all broadcast notifications. Admin role required. + tags: + - Notification + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/BroadcastNotification' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/notification/broadcast/delete/id/{broadcast_id}/: + delete: + operationId: notification_broadcast_delete_id_destroy + description: Delete a single broadcast notification by its ID. Admin role required. + parameters: + - in: path + name: broadcast_id + schema: + type: string + required: true + tags: + - Notification + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/BroadcastNotification' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/notification/broadcast/list/all/: + get: + operationId: notification_broadcast_list_all_list + description: Admin only. List all broadcast notifications. Each record includes + a `target_details` field with the resolved human-readable name of the target + audience. + tags: + - Notification + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/BroadcastNotificationAdmin' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/notification/broadcast/update/id/{broadcast_id}/: + patch: + operationId: notification_broadcast_update_id_partial_update + description: Admin only. Partially update a broadcast notification by its ID. + All fields are optional. + parameters: + - in: path + name: broadcast_id + schema: + type: string + required: true + tags: + - Notification + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/BroadcastNotificationAdmin' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/notification/delete/all/: + delete: + operationId: notification_delete_all_destroy + description: Delete Notification Delete All. + tags: + - Notification + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Notification' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/notification/delete/id/{notification_id}/: + delete: + operationId: notification_delete_id_destroy + description: Delete Notification Delete. + parameters: + - in: path + name: notification_id + schema: + type: string + required: true + tags: + - Notification + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Notification' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/notification/list/: + get: + operationId: notification_list_retrieve + description: 'Retrieve all notifications for the authenticated user. Returns + two lists: `direct` (personal) and `broadcasts` (group/audience).' + tags: + - Notification + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/NotificationListResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/protected/organisation/get-institutes/{district_name}/: + get: + operationId: protected_organisation_get_institutes_retrieve + description: Retrieve Retrieve Institutes. + parameters: + - in: path + name: district_name + schema: + type: string + required: true + tags: + - Protected + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InstitutesRetrival' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/protected/organisation/institutes/{organisation_type}/{district_name}/: + get: + operationId: protected_organisation_institutes_retrieve + description: Retrieve Get Institutions. + parameters: + - in: path + name: district_name + schema: + type: string + required: true + - in: path + name: organisation_type + schema: + type: string + required: true + tags: + - Protected + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Organisation' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/{circle_id}/lc-details/: + get: + operationId: public_lc_details_retrieve + description: Retrieve Lc Details. + parameters: + - in: path + name: circle_id + schema: + type: string + required: true + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonLcDetailsResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/campus-details/{college_code}/: + get: + operationId: public_campus_details_retrieve + parameters: + - in: path + name: college_code + schema: + type: string + required: true + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/public/college-wise-lc-report/: + get: + operationId: public_college_wise_lc_report_retrieve + description: Retrieve College Wise Lc Report. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CollegeInfo' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/college-wise-lc-report/csv/: + get: + operationId: public_college_wise_lc_report_csv_retrieve + description: Retrieve College Wise Lc Report C S V. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CollegeInfo' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/external/user/: + get: + operationId: public_external_user_retrieve + description: Retrieve External User Details. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/ExternalUserDetails' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/global-count/: + get: + operationId: public_global_count_retrieve + description: Retrieve Global Count. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonGlobalCountResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/gta-sandshore/: + get: + operationId: public_gta_sandshore_retrieve + description: Retrieve G T A S A N D S H O R E. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonGtasandshoreResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/ig/{id}/: + get: + operationId: public_ig_retrieve + description: Retrieve I G Detail. + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/InterestGroup' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/jobs/: + get: + operationId: public_jobs_list + description: Public endpoint to list all active jobs. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/JobList' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/lc-dashboard/: + get: + operationId: public_lc_dashboard_retrieve + description: Retrieve Lc Dashboard. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonLcDashboardResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/lc-enrollment/: + get: + operationId: public_lc_enrollment_retrieve + description: Retrieve Learning Circle Enrollment. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleEnrollment' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/lc-enrollment/csv/: + get: + operationId: public_lc_enrollment_csv_retrieve + description: Retrieve Learning Circle Enrollment C S V. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleEnrollment' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/lc-list: + get: + operationId: public_lc_list_retrieve + description: Retrieve Lc List. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonLcListResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/lc-report/: + get: + operationId: public_lc_report_retrieve + description: Retrieve Lc Report. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/StudentInfo' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/lc-report/csv/: + get: + operationId: public_lc_report_csv_retrieve + description: Retrieve Lc Report Download. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/StudentInfo' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/leaderboard/top-100/: + get: + operationId: public_leaderboard_top_100_retrieve + description: Retrieve Beken. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserLeaderboard' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/list-ig/: + get: + operationId: public_list_ig_retrieve + description: Retrieve List I G. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonListIGResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/list-ig-top100/: + get: + operationId: public_list_ig_top100_retrieve + description: Retrieve List Top Ig Users. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonListTopIgUsersResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/list/college/: + get: + operationId: public_list_college_retrieve + description: Retrieve Lc College. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Org' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/list/country/: + get: + operationId: public_list_country_retrieve + description: Retrieve Lc Country. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Country' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/list/district/: + get: + operationId: public_list_district_retrieve + description: Retrieve Lc District. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/District' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/list/levels/: + get: + operationId: public_list_levels_retrieve + description: Retrieve List All Level Info. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonListAllLevelInfoResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/list/state/: + get: + operationId: public_list_state_retrieve + description: Retrieve Lc State. + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/State' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/public/profile-pic/{muid}/: + get: + operationId: public_profile_pic_retrieve + description: Retrieve User Profile Pic. + parameters: + - in: path + name: muid + schema: + type: string + required: true + tags: + - Public + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/CommonUserProfilePicResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/: + post: + operationId: register_create + description: Create Register Data. + tags: + - Register + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Register' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Register' + multipart/form-data: + schema: + $ref: '#/components/schemas/Register' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserDetail' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/area-of-interest/list/: + get: + operationId: register_area_of_interest_list_retrieve + description: Retrieve Area Of Interest. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/AreaOfInterestAPI' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/college/list/: + post: + operationId: register_college_list_create + description: Create College. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Org' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/colleges/: + get: + operationId: register_colleges_retrieve + description: Retrieve Colleges. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/RegisterCollegesResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/community/list/: + get: + operationId: register_community_list_retrieve + description: Retrieve Community. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Org' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/company/list/: + get: + operationId: register_company_list_retrieve + description: Retrieve Company. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/RegisterCompaniesResponse' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/connect-discord/: + get: + operationId: register_connect_discord_retrieve + description: Retrieve Connect Discord. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + description: Discord onboard success message + /api/v1/register/country/list/: + get: + operationId: register_country_list_retrieve + description: Retrieve Country. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Country' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/department/list/: + get: + operationId: register_department_list_retrieve + description: Retrieve Department. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Department' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/district/list/: + post: + operationId: register_district_list_create + description: Create District. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/District' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/email-verification/: + post: + operationId: register_email_verification_create + description: Create User Email Verification. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/User' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/lc/user-validation/: + post: + operationId: register_lc_user_validation_create + description: Create Learning Circle User View. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/LearningCircleUser' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/location/: + get: + operationId: register_location_retrieve + description: Retrieve Location Search. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Location' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/organization/create/: + post: + operationId: register_organization_create_create + description: Create Unverified Organization Create. + tags: + - Register + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UnverifiedOrganizationCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UnverifiedOrganizationCreate' + multipart/form-data: + schema: + $ref: '#/components/schemas/UnverifiedOrganizationCreate' + required: true + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UnverifiedOrganizationCreate' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/role/list/: + get: + operationId: register_role_list_retrieve + description: Retrieve Role. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Role' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/schools/list/: + post: + operationId: register_schools_list_create + description: Create School. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/Org' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/select-domains/: + post: + operationId: register_select_domains_create + description: Create User Domain Selection. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/User' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/select-endgoals/: + post: + operationId: register_select_endgoals_create + description: Create User Endgoal Selection. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/User' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/state/list/: + post: + operationId: register_state_list_create + description: Create State. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/State' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/user-country/: + get: + operationId: register_user_country_retrieve + description: Retrieve User Country. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserCountry' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/user-state/: + get: + operationId: register_user_state_retrieve + description: Retrieve User State. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserState' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/register/user-zone/: + get: + operationId: register_user_zone_retrieve + description: Retrieve User Zone. + tags: + - Register + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + $ref: '#/components/schemas/UserZone' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/top100/leaderboard/: + get: + operationId: top100_leaderboard_list + description: Retrieve Leaderboard. + tags: + - Top100 + security: + - cookieAuth: [] + - basicAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + type: object + properties: + hasError: + type: boolean + default: false + example: false + statusCode: + type: integer + default: 200 + example: 200 + message: + type: object + properties: + general: + type: array + items: + type: string + response: + type: array + items: + $ref: '#/components/schemas/Top100LeaderboardItem' + required: + - hasError + - statusCode + - message + - response + description: '' + /api/v1/url-shortener/create/: + get: + operationId: url_shortener_create_retrieve + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: url_shortener_create_create + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: url_shortener_create_update + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: url_shortener_create_destroy + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/url-shortener/delete/{url_id}/: + get: + operationId: url_shortener_delete_retrieve + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: url_shortener_delete_create + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: url_shortener_delete_update + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: url_shortener_delete_destroy + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/url-shortener/edit/{url_id}/: + get: + operationId: url_shortener_edit_retrieve + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: url_shortener_edit_create + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: url_shortener_edit_update + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: url_shortener_edit_destroy + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body + /api/v1/url-shortener/get-analytics/{url_id}/: + get: + operationId: url_shortener_get_analytics_retrieve + parameters: + - in: path + name: url_id + schema: + type: string + required: true + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + /api/v1/url-shortener/list/: + get: + operationId: url_shortener_list_retrieve + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + post: + operationId: url_shortener_list_create + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + put: + operationId: url_shortener_list_update + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '200': + description: No response body + delete: + operationId: url_shortener_list_destroy + tags: + - Url Shortener + security: + - jwtAuth: [] + - {} + responses: + '204': + description: No response body +components: + schemas: + Achievement: + type: object + properties: + id: + type: string + maxLength: 36 + icon_url: + type: string + readOnly: true + has_achievement: + type: string + readOnly: true + name: + type: string + maxLength: 75 + description: + type: string + maxLength: 300 + icon: + type: string + maxLength: 100 + has_vc: + type: boolean + tags: {} + type: + type: string + maxLength: 36 + template_id: + type: string + nullable: true + maxLength: 100 + updated_at: + type: string + format: date-time + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + level_id: + type: string + nullable: true + updated_by: + type: string + created_by: + type: string + required: + - created_at + - created_by + - description + - has_achievement + - has_vc + - icon_url + - name + - tags + - type + - updated_at + - updated_by + AchievementAuditLogItem: + type: object + properties: + id: + type: string + achievement_id: + type: string + achievement_name: + type: string + action: + type: string + description: e.g. ISSUED, REVOKED + rule_version: + type: integer + nullable: true + metadata: + type: object + additionalProperties: {} + nullable: true + performed_by: + type: string + nullable: true + created_at: + type: string + format: date-time + nullable: true + required: + - achievement_id + - achievement_name + - action + - created_at + - id + - metadata + - performed_by + - rule_version + AchievementAuditLogResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: {} + default: {} + response: + type: array + items: + $ref: '#/components/schemas/AchievementAuditLogItem' + description: Last 100 audit log entries for the given user's achievements + required: + - response + AchievementBasic: + type: object + properties: + id: + type: string + maxLength: 36 + achievement_name: + type: string + description: + type: string + maxLength: 300 + icon: + type: string + maxLength: 100 + icon_url: + type: string + readOnly: true + level_id: + type: string + nullable: true + tags: {} + template_id: + type: string + nullable: true + maxLength: 100 + required: + - achievement_name + - description + - icon_url + - tags + AchievementProgressItem: + type: object + properties: + achievement_id: + type: string + achievement_name: + type: string + eligible: + type: boolean + claimed: + type: boolean + reason: + type: string + nullable: true + progress: + type: object + additionalProperties: {} + description: Rule-engine progress data (e.g. current vs. required counts) + required: + - achievement_id + - achievement_name + - claimed + - eligible + - progress + - reason + AchievementSimulateItem: + type: object + properties: + achievement_id: + type: string + achievement_name: + type: string + eligible: + type: boolean + claimed: + type: boolean + reason: + type: string + nullable: true + progress: + type: object + additionalProperties: {} + description: Rule-engine progress data for the target user + required: + - achievement_id + - achievement_name + - claimed + - eligible + - progress + - reason + AchievementSimulateRulesResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: {} + default: {} + response: + type: array + items: + $ref: '#/components/schemas/AchievementSimulateItem' + description: Simulated rule evaluation results for the given user (by muid) + required: + - response + AchievementUserProgressResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: {} + default: {} + response: + type: array + items: + $ref: '#/components/schemas/AchievementProgressItem' + description: Progress towards every achievement for the current user + required: + - response + AddMember: + type: object + description: 'Accepts exactly one identity: muid, user_id, or external_name.' + properties: + muid: + type: string + user_id: + type: string + external_name: + type: string + maxLength: 100 + role: + type: string + nullable: true + maxLength: 50 + AffiliationList: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 75 + organization_count: + type: string + readOnly: true + created_by: + type: string + updated_by: + type: string + updated_at: + type: string + format: date-time + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - created_by + - organization_count + - title + - updated_at + - updated_by + ApplicationTracking: + type: object + properties: + id: + type: string + readOnly: true + job: + type: string + readOnly: true + applicant_name: + type: string + readOnly: true + applicant_email: + type: string + readOnly: true + resume_link: + type: string + readOnly: true + nullable: true + cover_letter: + type: string + readOnly: true + nullable: true + status: + type: string + maxLength: 20 + rejection_reason: + type: string + nullable: true + applied_at: + type: string + format: date-time + readOnly: true + required: + - applicant_email + - applicant_name + - applied_at + - cover_letter + - id + - job + - resume_link + AreaOfInterestAPI: + type: object + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + required: + - name + AuthAppleTokenResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: {} + default: {} + response: + type: object + additionalProperties: {} + description: Upstream auth payload (access_token, refresh_token, user info, + etc.) + required: + - response + AuthRefreshTokenResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: {} + default: {} + response: + type: object + additionalProperties: {} + description: Upstream token payload (new access_token, refresh_token, expiry, + etc.) + required: + - response + AuthTokenResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: {} + default: {} + response: + type: object + additionalProperties: {} + description: Upstream auth payload (access_token, refresh_token, user info, + etc.) + required: + - response + AuthUserAuthResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: {} + default: {} + response: + type: object + additionalProperties: {} + description: Upstream auth payload (access_token, refresh_token, user info, + etc.) + required: + - response + AvailabilitySlot: + type: object + properties: + id: + type: string + maxLength: 36 + mentor_user_id: + type: string + readOnly: true + ig_id: + type: string + nullable: true + readOnly: true + ig_name: + type: string + readOnly: true + weekday: + type: integer + maximum: 32767 + minimum: -32768 + start_time: + type: string + format: time + end_time: + type: string + format: time + timezone: + type: string + maxLength: 64 + is_active: + type: boolean + valid_from: + type: string + format: date + nullable: true + valid_to: + type: string + format: date + nullable: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - end_time + - ig_id + - ig_name + - mentor_user_id + - start_time + - updated_at + - weekday + AvailabilitySlotCreateUpdate: + type: object + properties: + ig: + type: string + nullable: true + weekday: + type: integer + maximum: 32767 + minimum: -32768 + start_time: + type: string + format: time + end_time: + type: string + format: time + timezone: + type: string + maxLength: 64 + is_active: + type: boolean + valid_from: + type: string + format: date + nullable: true + valid_to: + type: string + format: date + nullable: true + required: + - end_time + - start_time + - weekday + BankTransfer: + type: object + description: Serializer for bank transfer donation requests (amount >= 5L) + properties: + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + name: + type: string + maxLength: 255 + donation_name: + type: string + maxLength: 100 + email: + type: string + format: email + phone_number: + type: string + maxLength: 20 + pan_number: + type: string + maxLength: 10 + address: + type: string + company: + type: string + maxLength: 255 + donation_type: + enum: + - one-time + - monthly + - yearly + type: string + description: |- + * `one-time` - one-time + * `monthly` - monthly + * `yearly` - yearly + x-spec-enum-id: a1444fc7968e746a + default: one-time + is_organisation: + type: boolean + default: false + proof_url: + type: string + format: uri + maxLength: 2000 + reference_code: + type: string + maxLength: 50 + required: + - amount + - email + - name + - reference_code + BroadcastNotification: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + title: + type: string + maxLength: 50 + description: + type: string + maxLength: 200 + url: + type: string + nullable: true + maxLength: 100 + target_type: + type: string + maxLength: 30 + target_id: + type: string + nullable: true + maxLength: 36 + created_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + created_by: + type: string + required: + - created_at + - created_by + - description + - expires_at + - id + - target_type + - title + BroadcastNotificationAdmin: + type: object + description: |- + Extended serializer for admin CRUD on BroadcastNotification. + Adds a `target_details` field that resolves the human-readable name of + the target entity from target_type + target_id. + properties: + id: + type: string + format: uuid + readOnly: true + title: + type: string + maxLength: 50 + description: + type: string + maxLength: 200 + url: + type: string + nullable: true + maxLength: 100 + target_type: + type: string + maxLength: 30 + target_id: + type: string + nullable: true + maxLength: 36 + target_details: + type: string + readOnly: true + created_by: + type: string + readOnly: true + created_by_name: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + expires_at: + type: string + format: date-time + required: + - created_at + - created_by + - created_by_name + - description + - expires_at + - id + - target_details + - target_type + - title + CampusCircleHealthData: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/CampusCircleHealthItem' + required: + - data + CampusCircleHealthItem: + type: object + properties: + circle_id: + type: string + circle_name: + type: string + ig_id: + type: string + ig_name: + type: string + nullable: true + member_count: + type: integer + sessions_per_month: + type: integer + last_session_at: + type: string + nullable: true + status: + type: string + required: + - circle_id + - circle_name + - ig_id + - ig_name + - last_session_at + - member_count + - sessions_per_month + - status + CampusCircleHealthResponse: + type: object + properties: + hasError: + type: boolean + statusCode: + type: integer + message: + type: object + additionalProperties: {} + response: + $ref: '#/components/schemas/CampusCircleHealthData' + required: + - hasError + - message + - response + - statusCode + CampusDashboardCircleHealth: + type: object + properties: + circle_id: + type: string + circle_name: + type: string + ig_id: + type: string + ig_name: + type: string + nullable: true + member_count: + type: integer + sessions_per_month: + type: integer + last_session_at: + type: string + nullable: true + status: + type: string + required: + - circle_id + - circle_name + - ig_id + - ig_name + - last_session_at + - member_count + - sessions_per_month + - status + CampusDashboardFunnelStage: + type: object + properties: + key: + type: string + label: + type: string + count: + type: integer + percentage: + type: number + format: double + required: + - count + - key + - label + - percentage + CampusDashboardMemberFunnel: + type: object + properties: + max: + type: integer + stages: + type: array + items: + $ref: '#/components/schemas/CampusDashboardFunnelStage' + required: + - max + - stages + CampusDashboardRecentActivity: + type: object + properties: + id: + type: string + type: + type: string + title: + type: string + description: + type: string + created_at: + type: string + nullable: true + actor: + type: object + additionalProperties: {} + metadata: + type: object + additionalProperties: {} + required: + - actor + - created_at + - description + - id + - metadata + - title + - type + CampusDashboardStatCard: + type: object + properties: + key: + type: string + label: + type: string + value: + type: integer + nullable: true + delta: + type: integer + delta_type: + type: string + period: + type: string + required: + - delta + - delta_type + - key + - label + - period + - value + CampusDashboardSummaryCampus: + type: object + properties: + org_id: + type: string + college_name: + type: string + campus_code: + type: string + campus_zone: + type: string + nullable: true + required: + - campus_code + - campus_zone + - college_name + - org_id + CampusDashboardSummaryData: + type: object + properties: + campus: + $ref: '#/components/schemas/CampusDashboardSummaryCampus' + stat_cards: + type: array + items: + $ref: '#/components/schemas/CampusDashboardStatCard' + member_funnel: + $ref: '#/components/schemas/CampusDashboardMemberFunnel' + circle_health: + type: array + items: + $ref: '#/components/schemas/CampusDashboardCircleHealth' + recent_activity: + type: array + items: + $ref: '#/components/schemas/CampusDashboardRecentActivity' + required: + - campus + - circle_health + - member_funnel + - recent_activity + - stat_cards + CampusDashboardSummaryResponse: + type: object + properties: + hasError: + type: boolean + statusCode: + type: integer + message: + type: object + additionalProperties: {} + response: + $ref: '#/components/schemas/CampusDashboardSummaryData' + required: + - hasError + - message + - response + - statusCode + CampusDetailsPublic: + type: object + properties: + org_id: + type: string + readOnly: true + college_name: + type: string + readOnly: true + campus_code: + type: string + readOnly: true + campus_zone: + type: string + readOnly: true + campus_level: + type: string + readOnly: true + total_karma: + type: string + readOnly: true + total_members: + type: string + readOnly: true + active_members: + type: string + readOnly: true + rank: + type: string + readOnly: true + social_links: + type: string + readOnly: true + required: + - active_members + - campus_code + - campus_level + - campus_zone + - college_name + - org_id + - rank + - social_links + - total_karma + - total_members + CampusKarmaByClusterCategory: + type: object + properties: + total_karma: + type: integer + member_count: + type: integer + required: + - member_count + - total_karma + CampusKarmaByClusterResponse: + type: object + properties: + hasError: + type: boolean + statusCode: + type: integer + message: + type: object + additionalProperties: {} + response: + $ref: '#/components/schemas/CampusKarmaByClusterCategory' + required: + - hasError + - message + - response + - statusCode + CampusLeaderboard: + type: object + properties: + user_id: + type: string + full_name: + type: string + readOnly: true + muid: + type: string + karma: + type: integer + rank: + type: string + readOnly: true + level: + type: string + join_date: + type: string + last_karma_gained: + type: string + graduation_year: + type: string + department: + type: string + is_alumni: + type: boolean + ig_count: + type: integer + default: 0 + lc_count: + type: integer + default: 0 + profile_pic: + type: string + readOnly: true + required: + - department + - full_name + - graduation_year + - is_alumni + - join_date + - karma + - last_karma_gained + - level + - muid + - profile_pic + - rank + - user_id + CampusList: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 100 + code: + type: string + maxLength: 12 + required: + - code + - title + CampusMemberFunnelData: + type: object + properties: + max: + type: integer + stages: + type: array + items: + $ref: '#/components/schemas/CampusMemberFunnelStage' + required: + - max + - stages + CampusMemberFunnelResponse: + type: object + properties: + hasError: + type: boolean + statusCode: + type: integer + message: + type: object + additionalProperties: {} + response: + $ref: '#/components/schemas/CampusMemberFunnelData' + required: + - hasError + - message + - response + - statusCode + CampusMemberFunnelStage: + type: object + properties: + key: + type: string + label: + type: string + count: + type: integer + percentage: + type: number + format: double + required: + - count + - key + - label + - percentage + CampusMentorLeaderboard: + type: object + properties: + mentor_id: + type: string + mentor_name: + type: string + profile_pic: + type: string + readOnly: true + campus_name: + type: string + readOnly: true + total_karma: + type: integer + completed_sessions: + type: integer + rank: + type: string + readOnly: true + required: + - campus_name + - completed_sessions + - mentor_id + - mentor_name + - profile_pic + - rank + - total_karma + CampusRecentActivityData: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/CampusRecentActivityItem' + required: + - data + CampusRecentActivityItem: + type: object + properties: + id: + type: string + type: + type: string + title: + type: string + description: + type: string + created_at: + type: string + nullable: true + actor: + type: object + additionalProperties: {} + metadata: + type: object + additionalProperties: {} + required: + - actor + - created_at + - description + - id + - metadata + - title + - type + CampusRecentActivityResponse: + type: object + properties: + hasError: + type: boolean + statusCode: + type: integer + message: + type: object + additionalProperties: {} + response: + $ref: '#/components/schemas/CampusRecentActivityData' + required: + - hasError + - message + - response + - statusCode + CampusStudentInEachLevelResponse: + type: object + properties: + hasError: + type: boolean + statusCode: + type: integer + message: + type: object + additionalProperties: {} + response: + type: array + items: + $ref: '#/components/schemas/CampusStudentLevelItem' + required: + - hasError + - message + - response + - statusCode + CampusStudentLevelItem: + type: object + properties: + level: + type: integer + students: + type: integer + required: + - level + - students + CategoryList: + type: object + properties: + id: + type: string + maxLength: 36 + created_by: + type: string + readOnly: true + updated_by: + type: string + readOnly: true + name: + type: string + maxLength: 255 + description: + type: string + nullable: true + entity_id: + type: string + maxLength: 36 + entity_type: + enum: + - event + type: string + description: '* `event` - Event' + x-spec-enum-id: 956befa305ca9285 + updated_at: + type: string + format: date-time + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - created_by + - entity_id + - entity_type + - name + - updated_at + - updated_by + ChangePassword: + type: object + properties: + current_password: + type: string + new_password: + type: string + minLength: 8 + confirm_password: + type: string + minLength: 8 + required: + - confirm_password + - current_password + - new_password + ChannelList: + type: object + properties: + id: + type: string + maxLength: 36 + created_by: + type: string + updated_by: + type: string + name: + type: string + maxLength: 75 + discord_id: + type: string + maxLength: 36 + updated_at: + type: string + format: date-time + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - created_by + - discord_id + - id + - name + - updated_at + - updated_by + ChapterArchiveResponse: + type: object + properties: + id: + type: string + status: + type: string + required: + - id + - status + ChapterDeleteResponse: + type: object + properties: + id: + type: string + required: + - id + ChapterDetail: + type: object + description: Full serializer for chapter retrieval. + properties: + id: + type: string + readOnly: true + comic: + type: string + readOnly: true + title: + type: string + readOnly: true + slug: + type: string + readOnly: true + description: + type: string + readOnly: true + nullable: true + chapter_number: + type: string + format: decimal + pattern: ^-?\d{0,4}(?:\.\d{0,2})?$ + readOnly: true + cover_image_key: + type: string + readOnly: true + nullable: true + status: + enum: + - draft + - published + - archived + type: string + description: |- + * `draft` - Draft + * `published` - Published + * `archived` - Archived + x-spec-enum-id: 3999cf41a064d13a + readOnly: true + published_at: + type: string + format: date-time + readOnly: true + nullable: true + pages: + type: string + readOnly: true + created_by: + allOf: + - $ref: '#/components/schemas/MinimalUser' + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_by: + allOf: + - $ref: '#/components/schemas/MinimalUser' + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - chapter_number + - comic + - cover_image_key + - created_at + - created_by + - description + - id + - pages + - published_at + - slug + - status + - title + - updated_at + - updated_by + ChapterList: + type: object + description: Lean serializer for listing chapters (e.g. comic chapter index). + properties: + id: + type: string + readOnly: true + comic: + type: string + readOnly: true + title: + type: string + readOnly: true + slug: + type: string + readOnly: true + chapter_number: + type: string + format: decimal + pattern: ^-?\d{0,4}(?:\.\d{0,2})?$ + readOnly: true + cover_image_key: + type: string + readOnly: true + nullable: true + status: + enum: + - draft + - published + - archived + type: string + description: |- + * `draft` - Draft + * `published` - Published + * `archived` - Archived + x-spec-enum-id: 3999cf41a064d13a + readOnly: true + published_at: + type: string + format: date-time + readOnly: true + nullable: true + created_at: + type: string + format: date-time + readOnly: true + required: + - chapter_number + - comic + - cover_image_key + - created_at + - id + - published_at + - slug + - status + - title + ChapterPage: + type: object + description: Serializer representing individual pages in a chapter. + properties: + id: + type: string + readOnly: true + chapter: + type: string + page_number: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + image_key: + type: string + maxLength: 255 + created_at: + type: string + format: date-time + readOnly: true + required: + - chapter + - created_at + - id + - image_key + - page_number + ChapterPublishResponse: + type: object + properties: + id: + type: string + status: + type: string + required: + - id + - status + ChapterUploadRequest: + type: object + properties: + file: + type: string + format: uri + required: + - file + ChapterWrite: + type: object + description: |- + Input serializer for POST /chapters/ and PATCH /chapters//. + Caller never directly specifies slug, status, published_at, or audit fields. + properties: + comic: + type: string + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + chapter_number: + type: string + format: decimal + pattern: ^-?\d{0,4}(?:\.\d{0,2})?$ + cover_image_key: + type: string + nullable: true + maxLength: 255 + required: + - chapter_number + - comic + - title + CircleInvite: + type: object + description: Validates the invite request body for POST /invite//. + properties: + muid: + type: string + description: muid of the user to invite, e.g. user@mulearn + lead: + type: boolean + default: false + required: + - muid + CircleInviteStatus: + type: object + description: Serializes a pending invitation from the invitee's perspective. + properties: + link_id: + type: string + readOnly: true + circle_id: + type: string + readOnly: true + circle_title: + type: string + readOnly: true + ig: + type: string + readOnly: true + is_lead_invite: + type: boolean + readOnly: true + invited_by: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - circle_id + - circle_title + - created_at + - ig + - invited_by + - is_lead_invite + - link_id + CircleJoinRequest: + type: object + description: Serializes a user-initiated join request (is_invited=False, accepted=None) + from the lead's perspective. + properties: + link_id: + type: string + readOnly: true + user_id: + type: string + readOnly: true + full_name: + type: string + readOnly: true + profile_pic: + type: string + readOnly: true + nullable: true + muid: + type: string + readOnly: true + requested_at: + type: string + format: date-time + readOnly: true + required: + - full_name + - link_id + - muid + - profile_pic + - requested_at + - user_id + CircleMeetingLogCreateEdit: + type: object + properties: + circle_id: + type: string + title: + type: string + maxLength: 100 + description: + type: string + maxLength: 1000 + mode: + enum: + - online + - offline + type: string + description: |- + * `online` - Online + * `offline` - Offline + x-spec-enum-id: d6e757ef4e348283 + platform: + enum: + - Zoom + - Google Meet + - Microsoft Teams + - Discord + - Other + - null + type: string + x-spec-enum-id: 40f95a7913308104 + nullable: true + description: |- + Required for online meetings. One of: Zoom, Google Meet, Microsoft Teams, Other + + * `Zoom` - Zoom + * `Google Meet` - Google Meet + * `Microsoft Teams` - Microsoft Teams + * `Discord` - Discord + * `Other` - Other + meet_link: + type: string + format: uri + nullable: true + description: Required for online meetings. Full URL of the meeting. + meet_place: + type: string + nullable: true + description: Required for offline meetings. Address/venue name. + coord_x: + type: number + format: double + nullable: true + description: Required for offline meetings. Latitude coordinate. + coord_y: + type: number + format: double + nullable: true + description: Required for offline meetings. Longitude coordinate. + meet_time: + type: string + format: date-time + duration: + type: integer + maximum: 2147483647 + minimum: -2147483648 + is_recurring: + type: boolean + recurrence_type: + type: string + nullable: true + maxLength: 10 + recurrence: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + is_report_needed: + type: boolean + report_description: + type: string + nullable: true + maxLength: 1000 + required: + - circle_id + - description + - duration + - meet_time + - mode + - title + CircleMeetupInfo: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + readOnly: true + description: + type: string + maxLength: 1000 + mode: + enum: + - online + - offline + type: string + description: |- + * `online` - Online + * `offline` - Offline + x-spec-enum-id: d6e757ef4e348283 + meet_place: + type: string + readOnly: true + meet_link: + type: string + nullable: true + maxLength: 200 + meet_time: + type: string + format: date-time + readOnly: true + ig: + type: string + readOnly: true + is_report_needed: + type: boolean + readOnly: true + report_description: + type: string + readOnly: true + coord_x: + type: number + format: double + readOnly: true + coord_y: + type: number + format: double + readOnly: true + duration: + type: integer + readOnly: true + is_started: + type: string + readOnly: true + is_ended: + type: string + readOnly: true + attendees: + type: string + readOnly: true + is_member: + type: string + readOnly: true + meet_code: + type: string + readOnly: true + created_by_id: + type: string + readOnly: true + required: + - attendees + - coord_x + - coord_y + - created_by_id + - description + - duration + - ig + - is_ended + - is_member + - is_report_needed + - is_started + - meet_code + - meet_place + - meet_time + - mode + - report_description + - title + CircleMeetupMin: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + readOnly: true + description: + type: string + maxLength: 1000 + org: + type: string + readOnly: true + ig_id: + type: string + readOnly: true + ig_name: + type: string + readOnly: true + mode: + enum: + - online + - offline + type: string + description: |- + * `online` - Online + * `offline` - Offline + x-spec-enum-id: d6e757ef4e348283 + is_recurring: + type: boolean + recurrence_type: + type: string + nullable: true + maxLength: 10 + recurrence: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + meet_place: + type: string + readOnly: true + is_rsvp: + type: string + readOnly: true + circle_id: + type: string + readOnly: true + coord_x: + type: number + format: double + readOnly: true + coord_y: + type: number + format: double + readOnly: true + meet_time: + type: string + format: date-time + readOnly: true + meet_link: + type: string + nullable: true + maxLength: 200 + is_started: + type: string + readOnly: true + is_ended: + type: string + readOnly: true + is_joined: + type: string + readOnly: true + attendees_count: + type: string + readOnly: true + created_by: + type: string + readOnly: true + created_by_id: + type: string + readOnly: true + required: + - attendees_count + - circle_id + - coord_x + - coord_y + - created_by + - created_by_id + - description + - ig_id + - ig_name + - is_ended + - is_joined + - is_rsvp + - is_started + - meet_place + - meet_time + - mode + - org + - title + CircleMeetupPublic: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + readOnly: true + description: + type: string + maxLength: 1000 + org: + type: string + readOnly: true + ig_id: + type: string + readOnly: true + ig_name: + type: string + readOnly: true + mode: + enum: + - online + - offline + type: string + description: |- + * `online` - Online + * `offline` - Offline + x-spec-enum-id: d6e757ef4e348283 + is_recurring: + type: boolean + recurrence_type: + type: string + nullable: true + maxLength: 10 + recurrence: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + meet_place: + type: string + readOnly: true + circle_id: + type: string + meet_time: + type: string + format: date-time + readOnly: true + meet_link: + type: string + nullable: true + maxLength: 200 + is_started: + type: string + readOnly: true + is_ended: + type: string + readOnly: true + created_by: + type: string + readOnly: true + required: + - circle_id + - created_by + - description + - ig_id + - ig_name + - is_ended + - is_started + - meet_place + - meet_time + - mode + - org + - title + CircleSentInvites: + type: object + description: Serializes outgoing invites from the lead's perspective. + properties: + link_id: + type: string + readOnly: true + user_id: + type: string + readOnly: true + full_name: + type: string + readOnly: true + profile_pic: + type: string + readOnly: true + nullable: true + muid: + type: string + readOnly: true + is_lead_invite: + type: boolean + readOnly: true + status: + type: string + readOnly: true + invited_at: + type: string + format: date-time + readOnly: true + required: + - full_name + - invited_at + - is_lead_invite + - link_id + - muid + - profile_pic + - status + - user_id + CollegeChange: + type: object + properties: + org_id: + type: string + maxLength: 36 + department_id: + type: string + nullable: true + maxLength: 36 + required: + - org_id + CollegeData: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 100 + district_name: + type: string + state: + type: string + total_users: + type: integer + level1: + type: integer + level2: + type: integer + level3: + type: integer + level4: + type: integer + required: + - district_name + - level1 + - level2 + - level3 + - level4 + - state + - title + - total_users + CollegeInfo: + type: object + properties: + org_title: + type: string + learning_circle_count: + type: string + user_count: + type: string + required: + - learning_circle_count + - org_title + - user_count + CollegeList: + type: object + properties: + id: + type: string + maxLength: 36 + level: + type: integer + maximum: 2147483647 + minimum: -2147483648 + org: + type: string + number_of_members: + type: string + readOnly: true + total_karma: + type: string + readOnly: true + no_of_lc: + type: string + readOnly: true + no_of_alumni: + type: string + readOnly: true + required: + - no_of_alumni + - no_of_lc + - number_of_members + - org + - total_karma + ComicArchiveResponse: + type: object + properties: + id: + type: string + status: + type: string + required: + - id + - status + ComicDeleteResponse: + type: object + properties: + id: + type: string + required: + - id + ComicDetail: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 150 + slug: + type: string + maxLength: 75 + description: + type: string + nullable: true + cover_image_key: + type: string + nullable: true + maxLength: 255 + status: + enum: + - draft + - published + - archived + type: string + description: |- + * `draft` - Draft + * `published` - Published + * `archived` - Archived + x-spec-enum-id: 3999cf41a064d13a + like_count: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + comment_count: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + bookmark_count: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + published_at: + type: string + format: date-time + nullable: true + genres: + type: string + readOnly: true + contributors: + type: string + readOnly: true + created_by: + allOf: + - $ref: '#/components/schemas/MinimalUser' + readOnly: true + created_at: + type: string + format: date-time + updated_by: + allOf: + - $ref: '#/components/schemas/MinimalUser' + readOnly: true + updated_at: + type: string + format: date-time + required: + - contributors + - created_at + - created_by + - genres + - slug + - title + - updated_at + - updated_by + ComicGenreAssign: + type: object + description: |- + Input serializer for POST /comics/{comicId}/genres/. + Validates that: + - The genre exists. + - The genre is active (is_active=True). + - The genre is not already assigned to this comic. + properties: + genre_id: + type: string + required: + - genre_id + ComicGenreAssignResponse: + type: object + properties: + link_id: + type: string + genre: + $ref: '#/components/schemas/GenreMinimalObj' + required: + - genre + - link_id + ComicGenreRemoveResponse: + type: object + properties: + link_id: + type: string + required: + - link_id + ComicListItem: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 150 + slug: + type: string + maxLength: 75 + cover_image_key: + type: string + nullable: true + maxLength: 255 + status: + enum: + - draft + - published + - archived + type: string + description: |- + * `draft` - Draft + * `published` - Published + * `archived` - Archived + x-spec-enum-id: 3999cf41a064d13a + like_count: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + comment_count: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + bookmark_count: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + published_at: + type: string + format: date-time + nullable: true + created_by: + allOf: + - $ref: '#/components/schemas/MinimalUser' + readOnly: true + created_at: + type: string + format: date-time + genres: + type: string + readOnly: true + required: + - created_at + - created_by + - genres + - slug + - title + ComicPublishResponse: + type: object + properties: + id: + type: string + status: + type: string + required: + - id + - status + ComicWrite: + type: object + description: |- + Input serializer for POST /comics/ and PATCH /comics//. + The caller never sends: slug, status, *_count, published_at, or audit fields. + properties: + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + cover_image_key: + type: string + nullable: true + maxLength: 255 + required: + - title + Comment: + type: object + properties: + id: + type: string + maxLength: 36 + comment: + type: string + project: + type: string + user: + type: string + readOnly: true + user_id: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - comment + - created_at + - project + - updated_at + - user + - user_id + CommonGlobalCountData: + type: object + properties: + members: + type: integer + description: Total registered user count + org_type_counts: + type: array + items: + $ref: '#/components/schemas/CommonOrgTypeCount' + description: Count of organisations per type (college, company, community) + enablers_mentors_count: + type: array + items: + $ref: '#/components/schemas/CommonRoleCount' + description: Count of users with enabler / mentor roles + ig_count: + type: integer + description: Total interest group count + learning_circle_count: + type: integer + description: Total learning circle count + required: + - enablers_mentors_count + - ig_count + - learning_circle_count + - members + - org_type_counts + CommonGlobalCountResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + $ref: '#/components/schemas/CommonGlobalCountData' + required: + - response + CommonGtasandshoreResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + type: object + additionalProperties: + type: integer + description: Map of normalised college name → submission count from devfolio + required: + - response + CommonLcDashboardData: + type: object + properties: + lc_count: + type: integer + description: Total number of learning circles + total_enrollment: + type: integer + description: Total college-student enrollments + circle_count_by_ig: + type: array + items: + $ref: '#/components/schemas/CommonLcDashboardIgStat' + description: Circle and user counts grouped by interest group + unique_users: + type: integer + description: Number of unique enrolled users + required: + - circle_count_by_ig + - lc_count + - total_enrollment + - unique_users + CommonLcDashboardIgStat: + type: object + properties: + name: + type: string + total_circles: + type: integer + total_users: + type: integer + required: + - name + - total_circles + - total_users + CommonLcDashboardResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + $ref: '#/components/schemas/CommonLcDashboardData' + required: + - response + CommonLcDetailsData: + type: object + properties: + name: + type: string + circle_code: + type: string + note: + type: string + nullable: true + day: + type: string + nullable: true + college: + type: string + nullable: true + members: + type: array + items: + type: object + additionalProperties: {} + rank: + type: integer + nullable: true + total_karma: + type: integer + ig_id: + type: string + ig_name: + type: string + ig_code: + type: string + required: + - circle_code + - college + - day + - ig_code + - ig_id + - ig_name + - members + - name + - note + - rank + - total_karma + CommonLcDetailsResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + $ref: '#/components/schemas/CommonLcDetailsData' + required: + - response + CommonLcListItem: + type: object + properties: + id: + type: string + name: + type: string + ig_name: + type: string + org_name: + type: string + nullable: true + member_count: + type: integer + members: + type: array + items: + type: object + additionalProperties: {} + nullable: true + meet_place: + type: string + nullable: true + meet_time: + type: string + nullable: true + lead_name: + type: string + nullable: true + karma: + type: integer + required: + - id + - ig_name + - karma + - lead_name + - meet_place + - meet_time + - member_count + - members + - name + - org_name + CommonLcListResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + type: array + items: + $ref: '#/components/schemas/CommonLcListItem' + pagination: + type: object + additionalProperties: + type: integer + required: + - pagination + - response + CommonLevelItem: + type: object + properties: + name: + type: string + description: Level name + tasks: + type: array + items: + $ref: '#/components/schemas/CommonLevelTask' + required: + - name + - tasks + CommonLevelTask: + type: object + properties: + task_name: + type: string + discord_link: + type: string + nullable: true + hashtag: + type: string + nullable: true + active: + type: boolean + completed: + type: boolean + karma: + type: integer + task_description: + type: string + nullable: true + interest_group: + $ref: '#/components/schemas/CommonLevelTaskIG' + submission_channel: + $ref: '#/components/schemas/CommonLevelTaskChannel' + required: + - active + - completed + - discord_link + - hashtag + - interest_group + - karma + - submission_channel + - task_description + - task_name + CommonLevelTaskChannel: + type: object + properties: + id: + type: string + nullable: true + name: + type: string + nullable: true + discord_id: + type: string + nullable: true + required: + - discord_id + - id + - name + CommonLevelTaskIG: + type: object + properties: + id: + type: string + nullable: true + name: + type: string + nullable: true + required: + - id + - name + CommonListAllLevelInfoResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + type: array + items: + $ref: '#/components/schemas/CommonLevelItem' + description: Levels with their associated tasks + required: + - response + CommonListIGItem: + type: object + properties: + name: + type: string + description: Interest group name + required: + - name + CommonListIGResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + type: array + items: + $ref: '#/components/schemas/CommonListIGItem' + description: All interest groups + required: + - response + CommonListTopIgUsersResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + type: array + items: + $ref: '#/components/schemas/CommonTopIgUserItem' + description: Top 100 users ranked by karma in the requested interest groups + required: + - response + CommonOrgTypeCount: + type: object + properties: + org_type: + type: string + org_count: + type: integer + required: + - org_count + - org_type + CommonRoleCount: + type: object + properties: + role__title: + type: string + role_count: + type: integer + required: + - role__title + - role_count + CommonTopIgUserItem: + type: object + properties: + userid: + type: string + description: User UUID + muid: + type: string + description: User unique identifier + full_name: + type: string + ig_karma: + type: integer + description: Total karma earned in the requested interest groups + igs: + type: array + items: + type: string + description: All interest group names the user belongs to + required: + - full_name + - ig_karma + - igs + - muid + - userid + CommonUserProfilePicData: + type: object + properties: + image: + type: string + nullable: true + description: URL or path to the user's profile picture + required: + - image + CommonUserProfilePicResponse: + type: object + properties: + hasError: + type: boolean + default: false + statusCode: + type: integer + default: 200 + message: + type: object + additionalProperties: + type: string + default: {} + response: + $ref: '#/components/schemas/CommonUserProfilePicData' + required: + - response + CompanyDetail: + type: object + properties: + id: + type: string + maxLength: 36 + company_user_name: + type: string + readOnly: true + company_user_email: + type: string + readOnly: true + district_name: + type: string + readOnly: true + name: + type: string + maxLength: 75 + logo: + type: string + nullable: true + description: + type: string + short_pitch: + type: string + nullable: true + maxLength: 900 + industry_sector: + type: string + nullable: true + maxLength: 75 + website_link: + type: string + nullable: true + email: + type: string + nullable: true + maxLength: 100 + slug: + type: string + maxLength: 100 + status: + type: string + maxLength: 30 + location: + type: string + nullable: true + maxLength: 150 + legal_name: + type: string + nullable: true + maxLength: 150 + registration_number: + type: string + nullable: true + maxLength: 100 + tax_id: + type: string + nullable: true + maxLength: 100 + company_size: + type: string + nullable: true + maxLength: 50 + linkedin_url: + type: string + nullable: true + verification_document_url: + type: string + nullable: true + verification_requested_at: + type: string + format: date-time + nullable: true + verified_at: + type: string + format: date-time + nullable: true + verified_by: + type: string + nullable: true + maxLength: 36 + rejection_reason: + type: string + nullable: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + deleted_at: + type: string + format: date-time + nullable: true + updated_by: + type: string + nullable: true + maxLength: 36 + deleted_by: + type: string + nullable: true + maxLength: 36 + founded_year: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + remote_policy: + type: string + nullable: true + maxLength: 20 + culture_text: + type: string + nullable: true + tech_stack: + nullable: true + perks: + nullable: true + testimonials: + nullable: true + gallery: + nullable: true + company_user: + type: string + district: + type: string + nullable: true + state: + type: string + nullable: true + country: + type: string + nullable: true + required: + - company_user + - company_user_email + - company_user_name + - created_at + - description + - district_name + - name + - slug + - updated_at + CompanyGigAnalyticsResponse: + type: object + properties: + total_gigs_posted: + type: integer + active_gigs: + type: integer + closed_gigs: + type: integer + average_hourly_rate: + type: number + format: double + application_funnel: + type: object + additionalProperties: {} + conversion_rate: + type: string + required: + - active_gigs + - application_funnel + - average_hourly_rate + - closed_gigs + - conversion_rate + - total_gigs_posted + CompanyList: + type: object + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + slug: + type: string + maxLength: 100 + status: + type: string + maxLength: 30 + email: + type: string + nullable: true + maxLength: 100 + company_user_id: + type: string + readOnly: true + company_user_name: + type: string + readOnly: true + industry_sector: + type: string + nullable: true + maxLength: 75 + company_size: + type: string + nullable: true + maxLength: 50 + location: + type: string + nullable: true + maxLength: 150 + district_name: + type: string + readOnly: true + state_name: + type: string + readOnly: true + country_name: + type: string + readOnly: true + verification_requested_at: + type: string + format: date-time + nullable: true + verified_at: + type: string + format: date-time + nullable: true + required: + - company_user_id + - company_user_name + - country_name + - district_name + - name + - slug + - state_name + CompanyMentorList: + type: object + description: Serializer for listing Company Mentor nominations. + properties: + id: + type: string + maxLength: 36 + user_id: + type: string + readOnly: true + user_name: + type: string + readOnly: true + user_email: + type: string + readOnly: true + org_name: + type: string + readOnly: true + mentor_tier: + enum: + - IG_MENTOR + - MENTOR + - COMPANY_MENTOR + - CAMPUS_MENTOR + type: string + description: |- + * `IG_MENTOR` - IG Mentor + * `MENTOR` - Mentor + * `COMPANY_MENTOR` - Company Mentor + * `CAMPUS_MENTOR` - Campus Mentor + x-spec-enum-id: 502a7e5c2300b154 + status: + enum: + - PENDING + - APPROVED + - REJECTED + type: string + description: |- + * `PENDING` - Pending + * `APPROVED` - Approved + * `REJECTED` - Rejected + x-spec-enum-id: 1f00471a47c591be + reason: + type: string + nullable: true + maxLength: 1000 + verification_note: + type: string + nullable: true + maxLength: 500 + verified_at: + type: string + format: date-time + nullable: true + required: + - org_name + - user_email + - user_id + - user_name + CompanyMentorNominate: + type: object + description: |- + Nominate an existing platform user as a Company Mentor for the company. + + The nominated user is identified by their ``muid`` (e.g. john-doe@mulearn) + and must already be linked to the company's Organisation record. + properties: + muid: + type: string + description: MuID of the platform user to nominate (e.g. john-doe@mulearn). + reason: + type: string + description: Optional reason / note to pass with the nomination. + required: + - muid + CompanyRegister: + type: object + properties: + name: + type: string + maxLength: 75 + logo: + type: string + nullable: true + description: + type: string + short_pitch: + type: string + nullable: true + maxLength: 900 + industry_sector: + type: string + nullable: true + maxLength: 75 + website_link: + type: string + nullable: true + email: + type: string + nullable: true + maxLength: 100 + location: + type: string + nullable: true + maxLength: 150 + district_id: + type: string + nullable: true + state_id: + type: string + nullable: true + country_id: + type: string + nullable: true + legal_name: + type: string + nullable: true + maxLength: 150 + registration_number: + type: string + nullable: true + maxLength: 100 + tax_id: + type: string + nullable: true + maxLength: 100 + company_size: + type: string + nullable: true + maxLength: 50 + linkedin_url: + type: string + nullable: true + founded_year: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + remote_policy: + type: string + nullable: true + maxLength: 20 + culture_text: + type: string + nullable: true + tech_stack: + nullable: true + perks: + nullable: true + testimonials: + nullable: true + gallery: + nullable: true + required: + - description + - name + CompanyTaskCreate: + type: object + description: |- + Serializer for company task creation. + Similar to mentor task creation but IG is optional and no IG validation. + Locked fields (active, approval_status, requested_by, requested_at) + are injected by the view via serializer.save(**overrides). + properties: + hashtag: + type: string + maxLength: 75 + title: + type: string + maxLength: 75 + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + usage_count: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: + type: string + nullable: true + type: + type: string + level: + type: string + nullable: true + created_by: + type: string + updated_by: + type: string + required: + - created_by + - hashtag + - title + - type + - updated_by + CompanyTaskList: + type: object + description: Read-only list/detail serializer for company tasks. + properties: + id: + type: string + maxLength: 36 + hashtag: + type: string + maxLength: 75 + discord_link: + type: string + nullable: true + maxLength: 200 + title: + type: string + maxLength: 75 + description: + type: string + nullable: true + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + channel: + type: string + type: + type: string + active: + type: boolean + variable_karma: + type: boolean + usage_count: + type: integer + maximum: 2147483647 + minimum: -2147483648 + level: + type: string + org: + type: string + ig: + type: string + event: + type: string + nullable: true + maxLength: 50 + bonus_karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + bonus_time: + type: string + format: date-time + nullable: true + approval_status: + enum: + - approved + - pending + - rejected + type: string + description: |- + * `approved` - Approved + * `pending` - Pending + * `rejected` - Rejected + x-spec-enum-id: e9436f319d54eebe + rejection_reason: + type: string + nullable: true + reviewed_at: + type: string + format: date-time + nullable: true + requested_by_name: + type: string + requested_at: + type: string + format: date-time + nullable: true + skills: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - hashtag + - skills + - title + - type + - updated_at + CompanyTaskListResponse: + type: object + properties: + data: + type: array + items: + type: object + additionalProperties: {} + pagination: + type: object + additionalProperties: {} + required: + - data + - pagination + CompanyTaskUpdate: + type: object + description: Partial update serializer — same writable fields as create except + created_by. + properties: + hashtag: + type: string + maxLength: 75 + title: + type: string + maxLength: 75 + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + usage_count: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: + type: string + nullable: true + type: + type: string + level: + type: string + nullable: true + updated_by: + type: string + required: + - hashtag + - title + - type + - updated_by + CompanyUpdate: + type: object + properties: + name: + type: string + maxLength: 75 + logo: + type: string + nullable: true + description: + type: string + short_pitch: + type: string + nullable: true + maxLength: 900 + industry_sector: + type: string + nullable: true + maxLength: 75 + website_link: + type: string + nullable: true + email: + type: string + nullable: true + maxLength: 100 + location: + type: string + nullable: true + maxLength: 150 + district_id: + type: string + nullable: true + state_id: + type: string + nullable: true + country_id: + type: string + nullable: true + legal_name: + type: string + nullable: true + maxLength: 150 + registration_number: + type: string + nullable: true + maxLength: 100 + tax_id: + type: string + nullable: true + maxLength: 100 + company_size: + type: string + nullable: true + maxLength: 50 + linkedin_url: + type: string + nullable: true + founded_year: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + remote_policy: + type: string + nullable: true + maxLength: 20 + culture_text: + type: string + nullable: true + tech_stack: + nullable: true + perks: + nullable: true + testimonials: + nullable: true + gallery: + nullable: true + required: + - description + - name + ContributorAddResponse: + type: object + properties: + message: + type: string + required: + - message + ContributorList: + type: object + description: |- + Output shape for GET /comics/{comicId}/contributors/. + Flattens the ComicContributorLink → User / Comic relations. + properties: + contributor_id: + type: string + user_id: + type: string + role: + type: string + name: + type: string + comic_name: + type: string + required: + - comic_name + - contributor_id + - name + - role + - user_id + ContributorRemoveResponse: + type: object + properties: + message: + type: string + required: + - message + ContributorUpdateResponse: + type: object + properties: + message: + type: string + required: + - message + ContributorWrite: + type: object + description: |- + Input for POST /comics/{comicId}/contributors/. + Resolves `muid` → User object during validation so create() receives a + ready-to-use User instance. + properties: + muid: + type: string + role: + enum: + - writer + - artist + - colorist + - editor + type: string + description: |- + * `writer` - Writer + * `artist` - Artist + * `colorist` - Colorist + * `editor` - Editor + x-spec-enum-id: f103fc282a981e61 + required: + - muid + - role + Country: + type: object + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + required: + - name + CouponApplyResponse: + type: object + properties: + discount_type: + type: string + discount_value: + type: integer + ticket: + type: array + items: + type: string + required: + - discount_type + - discount_value + - ticket + CredentialInfo: + type: object + properties: + name: + type: string + description: + type: string + required: + - description + - name + Department: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 100 + required: + - title + DiscordPendingTasksResponse: + type: object + properties: + peer_pending: + type: integer + appraise_pending: + type: integer + required: + - appraise_pending + - peer_pending + District: + type: object + properties: + name: + type: string + maxLength: 75 + zone: + allOf: + - $ref: '#/components/schemas/Zone' + readOnly: true + required: + - name + - zone + Donation: + type: object + description: Serializer for Donation model - payment tracking + properties: + donation_type: + enum: + - one-time + - monthly + - yearly + type: string + description: |- + * `one-time` - one-time + * `monthly` - monthly + * `yearly` - yearly + x-spec-enum-id: a1444fc7968e746a + order_id: + type: string + nullable: true + maxLength: 100 + payment_id: + type: string + nullable: true + maxLength: 100 + donation_name: + type: string + nullable: true + maxLength: 100 + payment_method: + type: string + nullable: true + maxLength: 50 + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + currency: + type: string + maxLength: 10 + is_paid: + type: boolean + payment_status: + type: string + maxLength: 30 + reference_code: + type: string + nullable: true + maxLength: 50 + proof_url: + type: string + nullable: true + donor: + type: string + required: + - amount + - donation_type + - donor + DynamicTypeDropDownResponse: + type: object + properties: + types: + type: array + items: + type: string + required: + - types + EligibleStudent: + type: object + properties: + id: + type: string + maxLength: 36 + full_name: + type: string + maxLength: 150 + email: + type: string + readOnly: true + muid: + type: string + maxLength: 100 + profile_pic: + type: string + readOnly: true + karma: + type: integer + default: 0 + level: + type: string + college_name: + type: string + readOnly: true + interest_groups: + type: string + readOnly: true + roles: + type: string + readOnly: true + rank: + type: string + readOnly: true + karma_distribution: + type: string + readOnly: true + application_id: + type: string + readOnly: true + application_status: + type: string + readOnly: true + application_timeline: + type: string + readOnly: true + candidate_links: + type: string + readOnly: true + required: + - application_id + - application_status + - application_timeline + - candidate_links + - college_name + - email + - full_name + - interest_groups + - karma_distribution + - muid + - profile_pic + - rank + - roles + EventAnalyticsResponse: + type: object + properties: + summary: + type: object + additionalProperties: {} + interest_trend: + type: array + items: + type: object + additionalProperties: {} + task_breakdown: + type: array + items: + type: object + additionalProperties: {} + required: + - interest_trend + - summary + - task_breakdown + EventCalendarItem: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 200 + slug: + type: string + maxLength: 220 + status: + enum: + - draft + - pending_campus_approval + - pending_approval + - pending_mentor_approval + - published + - ongoing + - completed + - cancelled + - rejected + type: string + description: |- + * `draft` - Draft + * `pending_campus_approval` - Pending Campus Approval + * `pending_approval` - Pending Approval + * `pending_mentor_approval` - Pending Mentor Approval + * `published` - Published + * `ongoing` - Ongoing + * `completed` - Completed + * `cancelled` - Cancelled + * `rejected` - Rejected + x-spec-enum-id: 5369d79da504087c + start: + type: string + format: date-time + readOnly: true + end: + type: string + format: date-time + readOnly: true + venue_type: + enum: + - physical + - online + - hybrid + type: string + description: |- + * `physical` - Physical + * `online` - Online + * `hybrid` - Hybrid + x-spec-enum-id: 3a5e8fc6a728bf38 + organiser_name: + type: string + readOnly: true + category_name: + type: string + readOnly: true + nullable: true + is_featured: + type: boolean + required: + - category_name + - end + - organiser_name + - slug + - start + - title + EventCategoryItem: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + required: + - description + - id + - name + EventCoOwner: + type: object + properties: + id: + type: string + maxLength: 36 + entity_id: + type: string + maxLength: 36 + user: + type: string + readOnly: true + added_by: + type: string + readOnly: true + added_at: + type: string + format: date-time + required: + - added_at + - added_by + - entity_id + - user + EventCollaborationTargets: + type: object + properties: + ig: + type: array + items: + type: object + additionalProperties: {} + campus: + type: array + items: + type: object + additionalProperties: {} + company: + type: array + items: + type: object + additionalProperties: {} + campus_ig: + type: array + items: + type: object + additionalProperties: {} + required: + - campus + - campus_ig + - company + - ig + EventCollaborator: + type: object + properties: + id: + type: string + maxLength: 36 + entity_type: + enum: + - user_ticket + - co_owner + - collab_ig + - collab_campus + - collab_campus_ig + - collab_company + type: string + description: |- + * `user_ticket` - User Ticket + * `co_owner` - Co-owner + * `collab_ig` - Collaborating IG + * `collab_campus` - Collaborating Campus + * `collab_campus_ig` - Collaborating Campus IG + * `collab_company` - Collaborating Company + x-spec-enum-id: 2799704cb5801b2d + entity_id: + type: string + maxLength: 36 + entity_detail: + type: string + readOnly: true + role_label: + type: string + nullable: true + maxLength: 100 + invite_status: + enum: + - pending + - accepted + - rejected + - '' + - null + type: string + description: |- + * `pending` - Pending + * `accepted` - Accepted + * `rejected` - Rejected + x-spec-enum-id: 4874309ac8d0a714 + nullable: true + rejection_reason: + type: string + nullable: true + maxLength: 500 + responded_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - entity_detail + - entity_id + - entity_type + EventDetail: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 200 + slug: + type: string + maxLength: 220 + description: + type: string + nullable: true + cover_image: + type: string + readOnly: true + banner_image: + type: string + readOnly: true + category_id: + type: string + readOnly: true + nullable: true + category_name: + type: string + readOnly: true + nullable: true + status: + enum: + - draft + - pending_campus_approval + - pending_approval + - pending_mentor_approval + - published + - ongoing + - completed + - cancelled + - rejected + type: string + description: |- + * `draft` - Draft + * `pending_campus_approval` - Pending Campus Approval + * `pending_approval` - Pending Approval + * `pending_mentor_approval` - Pending Mentor Approval + * `published` - Published + * `ongoing` - Ongoing + * `completed` - Completed + * `cancelled` - Cancelled + * `rejected` - Rejected + x-spec-enum-id: 5369d79da504087c + scope: + enum: + - global + - campus + - ig + - campus_ig + - company + type: string + description: |- + * `global` - Global + * `campus` - Campus + * `ig` - Interest Group + * `campus_ig` - Campus IG + * `company` - Company + x-spec-enum-id: 76e3ce91196df376 + event_scope: + enum: + - maker + - coder + - manager + - creative + type: string + description: |- + * `maker` - Maker + * `coder` - Coder + * `manager` - Manager + * `creative` - Creative + x-spec-enum-id: 5c207dceb722502f + event_type: + enum: + - hackathon + - workshop + - webinar + - seminar + - bootcamp + - meetup + - conference + - competition + - ideathon + - cultural_event + - sports_event + - community_event + - expo + - networking_event + - tech_talk + - others + type: string + description: |- + * `hackathon` - Hackathon + * `workshop` - Workshop + * `webinar` - Webinar + * `seminar` - Seminar + * `bootcamp` - Bootcamp + * `meetup` - Meetup + * `conference` - Conference + * `competition` - Competition + * `ideathon` - Ideathon + * `cultural_event` - Cultural Event + * `sports_event` - Sports Event + * `community_event` - Community Event + * `expo` - Expo + * `networking_event` - Networking Event + * `tech_talk` - Tech Talk + * `others` - Others + x-spec-enum-id: 15edd4bbad4cc1b1 + scope_org: + allOf: + - $ref: '#/components/schemas/MinimalCampus' + readOnly: true + nullable: true + scope_ig: + allOf: + - $ref: '#/components/schemas/MinimalIG' + readOnly: true + nullable: true + scope_ci_id: + type: string + nullable: true + maxLength: 36 + organizer: + $ref: '#/components/schemas/OrganizerInfo' + venue: + $ref: '#/components/schemas/EventVenue' + start_datetime: + type: string + format: date-time + end_datetime: + type: string + format: date-time + registration_url: + type: string + nullable: true + maxLength: 500 + registration_deadline: + type: string + format: date-time + nullable: true + min_karma: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + nullable: true + is_featured: + type: boolean + is_collaboration: + type: boolean + interest_count: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + tags: + nullable: true + user_limit: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + linked_tasks: + type: string + readOnly: true + co_owners: + type: string + readOnly: true + collaborators: + type: string + readOnly: true + viewer_interest_status: + type: string + readOnly: true + viewer_can_access_registration: + type: string + readOnly: true + viewer_access_blocked_reason: + type: string + readOnly: true + created_by: + allOf: + - $ref: '#/components/schemas/MinimalUser' + readOnly: true + updated_by: + allOf: + - $ref: '#/components/schemas/MinimalUser' + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - banner_image + - category_id + - category_name + - co_owners + - collaborators + - cover_image + - created_at + - created_by + - end_datetime + - event_scope + - linked_tasks + - organizer + - scope_ig + - scope_org + - slug + - start_datetime + - title + - updated_at + - updated_by + - venue + - viewer_access_blocked_reason + - viewer_can_access_registration + - viewer_interest_status + EventInterestCreateResponse: + type: object + properties: + event_id: + type: string + user_id: + type: string + status: + type: string + required: + - event_id + - status + - user_id + EventListItem: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 200 + slug: + type: string + maxLength: 220 + cover_image: + type: string + readOnly: true + status: + enum: + - draft + - pending_campus_approval + - pending_approval + - pending_mentor_approval + - published + - ongoing + - completed + - cancelled + - rejected + type: string + description: |- + * `draft` - Draft + * `pending_campus_approval` - Pending Campus Approval + * `pending_approval` - Pending Approval + * `pending_mentor_approval` - Pending Mentor Approval + * `published` - Published + * `ongoing` - Ongoing + * `completed` - Completed + * `cancelled` - Cancelled + * `rejected` - Rejected + x-spec-enum-id: 5369d79da504087c + scope: + enum: + - global + - campus + - ig + - campus_ig + - company + type: string + description: |- + * `global` - Global + * `campus` - Campus + * `ig` - Interest Group + * `campus_ig` - Campus IG + * `company` - Company + x-spec-enum-id: 76e3ce91196df376 + event_scope: + enum: + - maker + - coder + - manager + - creative + type: string + description: |- + * `maker` - Maker + * `coder` - Coder + * `manager` - Manager + * `creative` - Creative + x-spec-enum-id: 5c207dceb722502f + event_type: + enum: + - hackathon + - workshop + - webinar + - seminar + - bootcamp + - meetup + - conference + - competition + - ideathon + - cultural_event + - sports_event + - community_event + - expo + - networking_event + - tech_talk + - others + type: string + description: |- + * `hackathon` - Hackathon + * `workshop` - Workshop + * `webinar` - Webinar + * `seminar` - Seminar + * `bootcamp` - Bootcamp + * `meetup` - Meetup + * `conference` - Conference + * `competition` - Competition + * `ideathon` - Ideathon + * `cultural_event` - Cultural Event + * `sports_event` - Sports Event + * `community_event` - Community Event + * `expo` - Expo + * `networking_event` - Networking Event + * `tech_talk` - Tech Talk + * `others` - Others + x-spec-enum-id: 15edd4bbad4cc1b1 + start_datetime: + type: string + format: date-time + end_datetime: + type: string + format: date-time + venue: + $ref: '#/components/schemas/EventVenue' + organizer: + $ref: '#/components/schemas/OrganizerInfo' + is_featured: + type: boolean + is_collaboration: + type: boolean + interest_count: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + min_karma: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + nullable: true + tags: + nullable: true + user_limit: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + category_id: + type: string + readOnly: true + nullable: true + category_name: + type: string + readOnly: true + nullable: true + viewer_interest_status: + type: string + readOnly: true + required: + - category_id + - category_name + - cover_image + - end_datetime + - event_scope + - organizer + - slug + - start_datetime + - title + - venue + - viewer_interest_status + EventOrganizerOptions: + type: object + properties: + can_create_as_ig: + type: array + items: + type: object + additionalProperties: {} + can_create_as_campus_ig: + type: array + items: + type: object + additionalProperties: {} + can_create_as_campus: + type: array + items: + type: object + additionalProperties: {} + can_create_as_company: + type: array + items: + type: object + additionalProperties: {} + can_create_as_admin: + type: boolean + campus_context: + type: object + additionalProperties: {} + nullable: true + required: + - campus_context + - can_create_as_admin + - can_create_as_campus + - can_create_as_campus_ig + - can_create_as_company + - can_create_as_ig + EventPublishResponse: + type: object + properties: + id: + type: string + status: + type: string + required: + - id + - status + EventTask: + type: object + description: Read serializer for event-linked tasks (list + detail responses). + properties: + id: + type: string + maxLength: 36 + hashtag: + type: string + maxLength: 75 + title: + type: string + maxLength: 75 + description: + type: string + nullable: true + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + approval_status: + enum: + - approved + - pending + - rejected + type: string + description: |- + * `approved` - Approved + * `pending` - Pending + * `rejected` - Rejected + x-spec-enum-id: e9436f319d54eebe + active: + type: boolean + type: + type: string + readOnly: true + ig: + allOf: + - $ref: '#/components/schemas/MinimalIG' + readOnly: true + level: + type: string + readOnly: true + channel: + type: string + org: + type: string + variable_karma: + type: boolean + usage_count: + type: integer + maximum: 2147483647 + minimum: -2147483648 + bonus_time: + type: string + format: date-time + nullable: true + bonus_karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + created_by: + allOf: + - $ref: '#/components/schemas/MinimalUser' + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - created_by + - hashtag + - ig + - level + - title + - type + - updated_at + EventTaskDeleteResponse: + type: object + properties: + message: + type: string + required: + - message + EventTaskMetaResponse: + type: object + properties: + task_types: + type: array + items: + type: object + additionalProperties: {} + interest_groups: + type: array + items: + type: object + additionalProperties: {} + levels: + type: array + items: + type: object + additionalProperties: {} + channels: + type: array + items: + type: object + additionalProperties: {} + organizations: + type: array + items: + type: object + additionalProperties: {} + required: + - channels + - interest_groups + - levels + - organizations + - task_types + EventTaskWrite: + type: object + description: Write serializer for creating/updating event-linked tasks. + properties: + hashtag: + type: string + maxLength: 75 + title: + type: string + maxLength: 75 + description: + type: string + nullable: true + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: + type: string + ig: + type: string + nullable: true + level: + type: string + nullable: true + org: + type: string + nullable: true + bonus_time: + type: string + format: date-time + nullable: true + required: + - hashtag + - title + - type + EventTypesScopesResponse: + type: object + properties: + event_type: + type: array + items: + type: string + event_scope: + type: array + items: + type: string + required: + - event_scope + - event_type + EventVenue: + type: object + description: Flattens venue_* fields from the Event model. + properties: + venue_type: + type: string + venue_address: + type: string + nullable: true + venue_city: + type: string + nullable: true + venue_maps_url: + type: string + format: uri + nullable: true + venue_online_link: + type: string + nullable: true + venue_platform: + type: string + nullable: true + required: + - venue_type + EventWrite: + type: object + description: Input serializer for POST /manage/ and PUT/PATCH /manage//. + properties: + title: + type: string + maxLength: 200 + description: + type: string + nullable: true + cover_image: + type: string + nullable: true + maxLength: 512 + banner_image: + type: string + nullable: true + maxLength: 512 + category: + type: string + nullable: true + start_datetime: + type: string + format: date-time + end_datetime: + type: string + format: date-time + registration_url: + type: string + nullable: true + maxLength: 500 + registration_deadline: + type: string + format: date-time + nullable: true + min_karma: + type: integer + maximum: 9223372036854775807 + minimum: -9223372036854775808 + format: int64 + nullable: true + venue_type: + enum: + - physical + - online + - hybrid + type: string + description: |- + * `physical` - Physical + * `online` - Online + * `hybrid` - Hybrid + x-spec-enum-id: 3a5e8fc6a728bf38 + venue_address: + type: string + nullable: true + maxLength: 300 + venue_city: + type: string + nullable: true + maxLength: 100 + venue_maps_url: + type: string + nullable: true + maxLength: 500 + venue_online_link: + type: string + nullable: true + maxLength: 500 + venue_platform: + type: string + nullable: true + maxLength: 100 + scope: + enum: + - global + - campus + - ig + - campus_ig + - company + type: string + description: |- + * `global` - Global + * `campus` - Campus + * `ig` - Interest Group + * `campus_ig` - Campus IG + * `company` - Company + x-spec-enum-id: 76e3ce91196df376 + scope_org: + type: string + nullable: true + scope_ig: + type: string + nullable: true + scope_ci_id: + type: string + nullable: true + maxLength: 36 + organiser_type: + enum: + - global_ig + - campus_ig + - campus + - company + - admin + type: string + description: |- + * `global_ig` - Global IG + * `campus_ig` - Campus IG + * `campus` - Campus + * `company` - Company + * `admin` - Admin + x-spec-enum-id: 1f717c4d9d7fa8e6 + organiser_ig: + type: string + nullable: true + organiser_org: + type: string + nullable: true + organiser_ci_id: + type: string + nullable: true + maxLength: 36 + is_collaboration: + type: boolean + is_featured: + type: boolean + tags: + nullable: true + user_limit: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + event_scope: + enum: + - maker + - coder + - manager + - creative + type: string + description: |- + * `maker` - Maker + * `coder` - Coder + * `manager` - Manager + * `creative` - Creative + x-spec-enum-id: 5c207dceb722502f + event_type: + enum: + - hackathon + - workshop + - webinar + - seminar + - bootcamp + - meetup + - conference + - competition + - ideathon + - cultural_event + - sports_event + - community_event + - expo + - networking_event + - tech_talk + - others + type: string + description: |- + * `hackathon` - Hackathon + * `workshop` - Workshop + * `webinar` - Webinar + * `seminar` - Seminar + * `bootcamp` - Bootcamp + * `meetup` - Meetup + * `conference` - Conference + * `competition` - Competition + * `ideathon` - Ideathon + * `cultural_event` - Cultural Event + * `sports_event` - Sports Event + * `community_event` - Community Event + * `expo` - Expo + * `networking_event` - Networking Event + * `tech_talk` - Tech Talk + * `others` - Others + x-spec-enum-id: 15edd4bbad4cc1b1 + required: + - end_datetime + - start_datetime + - title + ExternalUserDetails: + type: object + description: |- + Serializer for external API to fetch basic public user details. + + This serializer is specifically designed for external website integration, + returning only non-sensitive public information about users. + + Excludes: email, mobile, password, and other sensitive fields + properties: + full_name: + type: string + maxLength: 150 + muid: + type: string + maxLength: 100 + interest_groups: + type: string + readOnly: true + organizations: + type: string + readOnly: true + profile_pic: + type: string + readOnly: true + karma: + type: integer + default: 0 + required: + - full_name + - interest_groups + - muid + - organizations + - profile_pic + ForgotPassword: + type: object + properties: + email: + type: string + format: email + user_type: + enum: + - company + - recruiter + type: string + description: |- + * `company` - Company + * `recruiter` - Recruiter + x-spec-enum-id: df59bea2861b6389 + required: + - email + - user_type + GenreDeactivateResponse: + type: object + properties: + id: + type: string + is_active: + type: boolean + required: + - id + - is_active + GenreMinimalObj: + type: object + properties: + id: + type: string + name: + type: string + slug: + type: string + required: + - id + - name + - slug + GenreRead: + type: object + description: Output shape for genre list / detail responses. + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + slug: + type: string + maxLength: 75 + is_active: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - created_at + - name + - slug + - updated_at + GenreReinstateResponse: + type: object + properties: + id: + type: string + is_active: + type: boolean + required: + - id + - is_active + GenreWrite: + type: object + description: |- + Input serializer for POST /comics/genres/ and PATCH /comics/genres//. + Caller only sends `name`; slug and audit fields are set internally. + properties: + name: + type: string + maxLength: 75 + required: + - name + GetUserLink: + type: object + properties: + org_id: + type: string + org_title: + type: string + dept_id: + type: string + nullable: true + dept_title: + type: string + nullable: true + is_alumni: + type: boolean + required: + - is_alumni + - org_id + - org_title + HomeLearnerDashboardStats: + type: object + properties: + weekly_karma: + type: integer + total_karma: + type: integer + rank: + type: integer + active_circles: + type: integer + streak_days: + type: integer + required: + - active_circles + - rank + - streak_days + - total_karma + - weekly_karma + HomeLearnerDashboardSummaryResponse: + type: object + properties: + stats: + $ref: '#/components/schemas/HomeLearnerDashboardStats' + next_meeting: + nullable: true + quick_action_counts: + $ref: '#/components/schemas/HomeLearnerQuickActionCounts' + required: + - next_meeting + - quick_action_counts + - stats + HomeLearnerQuickActionCounts: + type: object + properties: + circles: + type: integer + leaderboard_rank: + type: integer + job_openings: + type: integer + required: + - circles + - job_openings + - leaderboard_rank + HomeLearnerStreakResponse: + type: object + properties: + streak_days: + type: integer + last_activity_at: + type: string + nullable: true + activity_dates: + type: array + items: + type: string + required: + - activity_dates + - last_activity_at + - streak_days + IGMentorLeaderboard: + type: object + properties: + mentor_id: + type: string + mentor_name: + type: string + profile_pic: + type: string + readOnly: true + ig_name: + type: string + readOnly: true + total_karma: + type: integer + completed_sessions: + type: integer + rank: + type: string + readOnly: true + required: + - completed_sessions + - ig_name + - mentor_id + - mentor_name + - profile_pic + - rank + - total_karma + InstitutesRetrival: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 100 + district: + type: string + required: + - district + - title + Institution: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 100 + code: + type: string + maxLength: 12 + affiliation: + type: string + readOnly: true + district: + type: string + readOnly: true + zone: + type: string + readOnly: true + state: + type: string + readOnly: true + country: + type: string + readOnly: true + user_count: + type: string + readOnly: true + required: + - affiliation + - code + - country + - district + - state + - title + - user_count + - zone + Integration: + type: object + properties: + param: + type: string + title: + type: string + required: + - param + - title + InterestGroup: + type: object + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + resource: + type: string + nullable: true + about: + type: string + nullable: true + prerequisites: + type: string + nullable: true + career_opportunities: + type: string + nullable: true + top_blogs: + type: string + nullable: true + people_to_follow: + type: string + nullable: true + leads: + type: string + nullable: true + mentors: + type: string + nullable: true + thinktank: + type: string + nullable: true + office_hours: + type: string + nullable: true + maxLength: 200 + icon: + type: string + maxLength: 10 + code: + type: string + maxLength: 10 + category: + enum: + - maker + - coder + - creative + - manager + - others + type: string + description: |- + * `maker` - maker + * `coder` - coder + * `creative` - creative + * `manager` - manager + * `others` - others + x-spec-enum-id: 61a28b192287f1ef + status: + enum: + - active + - requested + - cancelled + - rejected + type: string + description: |- + * `active` - active + * `requested` - requested + * `cancelled` - cancelled + * `rejected` - rejected + x-spec-enum-id: 2935644142754e9a + members: + type: string + readOnly: true + updated_by: + type: string + updated_at: + type: string + format: date-time + readOnly: true + created_by: + type: string + created_at: + type: string + format: date-time + readOnly: true + required: + - category + - code + - created_at + - created_by + - icon + - members + - name + - status + - updated_at + - updated_by + IssueVC: + type: object + properties: + subject_info: + $ref: '#/components/schemas/SubjectInfo' + credential_info: + $ref: '#/components/schemas/CredentialInfo' + template_id: + type: string + required: + - credential_info + - subject_info + - template_id + JobApplication: + type: object + properties: + id: + type: string + readOnly: true + job: + type: string + resume_link: + type: string + nullable: true + cover_letter: + type: string + nullable: true + status: + type: string + readOnly: true + required: + - id + - job + - status + JobCreate: + type: object + properties: + id: + type: string + readOnly: true + title: + type: string + maxLength: 75 + experience: + type: string + nullable: true + maxLength: 20 + job_description: + type: string + nullable: true + location: + type: string + nullable: true + maxLength: 75 + salary_range: + type: string + nullable: true + maxLength: 36 + job_type: + type: string + maxLength: 20 + duration_value: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + duration_unit: + type: string + nullable: true + maxLength: 20 + hourly_rate: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + nullable: true + deliverables: + nullable: true + stipend: + type: string + nullable: true + maxLength: 75 + certificate_provided: + type: string + nullable: true + maxLength: 3 + rules: + type: array + items: + $ref: '#/components/schemas/JobRule' + required: + - id + - job_type + - title + JobList: + type: object + properties: + id: + type: string + maxLength: 36 + company_name: + type: string + readOnly: true + company_logo: + type: string + readOnly: true + title: + type: string + maxLength: 75 + experience: + type: string + nullable: true + maxLength: 20 + job_description: + type: string + nullable: true + location: + type: string + nullable: true + maxLength: 75 + salary_range: + type: string + nullable: true + maxLength: 36 + job_type: + type: string + maxLength: 20 + status: + type: string + maxLength: 20 + duration_value: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + duration_unit: + type: string + nullable: true + maxLength: 20 + hourly_rate: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + nullable: true + deliverables: + nullable: true + stipend: + type: string + nullable: true + maxLength: 75 + certificate_provided: + type: string + nullable: true + maxLength: 3 + rules: + type: array + items: + $ref: '#/components/schemas/JobRule' + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - company_logo + - company_name + - created_at + - job_type + - rules + - title + JobRule: + type: object + properties: + id: + type: string + readOnly: true + rule_type: + type: string + maxLength: 50 + rule_value: + type: string + maxLength: 150 + required: + - id + - rule_type + - rule_value + JobUpdate: + type: object + properties: + title: + type: string + maxLength: 75 + experience: + type: string + nullable: true + maxLength: 20 + job_description: + type: string + nullable: true + location: + type: string + nullable: true + maxLength: 75 + salary_range: + type: string + nullable: true + maxLength: 36 + job_type: + type: string + maxLength: 20 + status: + type: string + maxLength: 20 + duration_value: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + duration_unit: + type: string + nullable: true + maxLength: 20 + hourly_rate: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + nullable: true + deliverables: + nullable: true + stipend: + type: string + nullable: true + maxLength: 75 + certificate_provided: + type: string + nullable: true + maxLength: 3 + rules: + type: array + items: + $ref: '#/components/schemas/JobRule' + required: + - job_type + - title + KKEMUser: + type: object + properties: + mu_id: + type: string + email: + type: string + format: email + maxLength: 200 + jsid: + type: string + readOnly: true + total_karma: + type: string + readOnly: true + interest_groups: + type: string + readOnly: true + required: + - email + - interest_groups + - jsid + - mu_id + - total_karma + KarmaActivityLog: + type: object + properties: + id: + type: string + maxLength: 36 + full_name: + type: string + task_name: + type: string + status: + type: string + readOnly: true + discordlink: + type: string + required: + - discordlink + - full_name + - status + - task_name + KkemAuthorizationPatchResponse: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + required: + - accessToken + - refreshToken + KkemIntegrationLoginDataResponse: + type: object + properties: + email: + type: string + full_name: + type: string + muid: + type: string + link_id: + type: string + verified: + type: boolean + required: + - email + - full_name + - link_id + - muid + - verified + KkemIntegrationLoginResponse: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + data: + allOf: + - $ref: '#/components/schemas/KkemIntegrationLoginDataResponse' + nullable: true + required: + - accessToken + - refreshToken + LaunchPadID: + type: object + properties: + launchpad_id: + type: string + maxLength: 100 + required: + - launchpad_id + LaunchpadAcceptedStudentsResponse: + type: object + properties: + data: + type: array + items: + type: object + additionalProperties: {} + pagination: + type: object + additionalProperties: {} + required: + - data + - pagination + LaunchpadApplicationDecisionResponse: + type: object + properties: + application_id: + type: string + decision: + type: string + previous_status: + type: string + decision_made_at: + type: string + format: date-time + decision_made_by: + type: object + additionalProperties: {} + student_info: + type: object + additionalProperties: {} + job_info: + type: object + additionalProperties: {} + application_timeline: + type: object + additionalProperties: {} + required: + - application_id + - application_timeline + - decision + - decision_made_at + - decision_made_by + - job_info + - previous_status + - student_info + LaunchpadCompanies: + type: object + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 100 + poc_name: + type: string + maxLength: 100 + poc_role: + type: string + maxLength: 100 + poc_email: + type: string + maxLength: 100 + website: + type: string + format: uri + nullable: true + maxLength: 200 + description: + type: string + nullable: true + address: + type: string + nullable: true + maxLength: 255 + poc_phone: + type: string + maxLength: 20 + username: + type: string + maxLength: 50 + password: + type: string + maxLength: 255 + is_verified: + type: boolean + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - id + - name + - password + - poc_email + - poc_name + - poc_phone + - poc_role + - updated_at + - username + LaunchpadCompanyInfoResponse: + type: object + properties: + id: + type: string + name: + type: string + username: + type: string + poc_name: + type: string + nullable: true + poc_role: + type: string + nullable: true + poc_email: + type: string + nullable: true + poc_phone: + type: string + nullable: true + website: + type: string + nullable: true + description: + type: string + nullable: true + address: + type: string + nullable: true + is_verified: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + recruiters: + type: array + items: + type: object + additionalProperties: {} + required: + - address + - created_at + - description + - id + - is_verified + - name + - poc_email + - poc_name + - poc_phone + - poc_role + - recruiters + - updated_at + - username + - website + LaunchpadCompanyPublic: + type: object + properties: + name: + type: string + maxLength: 100 + website: + type: string + format: uri + nullable: true + maxLength: 200 + required: + - name + LaunchpadDetailsCountResponse: + type: object + properties: + total_participants: + type: integer + Level_1: + type: integer + Level_2: + type: integer + Level_3: + type: integer + Level_4: + type: integer + required: + - Level_1 + - Level_2 + - Level_3 + - Level_4 + - total_participants + LaunchpadHireRequestsResponse: + type: object + properties: + data: + type: array + items: + type: object + additionalProperties: {} + pagination: + type: object + additionalProperties: {} + required: + - data + - pagination + LaunchpadIGLeaderboardResponse: + type: object + properties: + data: + type: array + items: + type: object + additionalProperties: {} + pagination: + type: object + additionalProperties: {} + required: + - data + - pagination + LaunchpadJobUpdate: + type: object + properties: + title: + type: string + maxLength: 100 + skills: + type: string + nullable: true + maxLength: 255 + experience: + type: string + nullable: true + maxLength: 255 + location: + type: string + nullable: true + maxLength: 255 + salary_range: + type: string + nullable: true + maxLength: 50 + job_type: + type: string + nullable: true + maxLength: 50 + interest_groups: + type: string + maxLength: 255 + opening_type: + enum: + - General + - Task + type: string + description: |- + * `General` - General + * `Task` - Task + x-spec-enum-id: 4c885f269d4b7296 + task_description: + type: string + hashtags: + type: string + LaunchpadJobs: + type: object + properties: + id: + type: string + maxLength: 36 + company: + type: string + recruiter: + type: string + title: + type: string + maxLength: 100 + skills: + type: string + nullable: true + experience: + type: string + nullable: true + domain: + type: string + maxLength: 255 + opening_type: + type: string + nullable: true + default: General + location: + type: string + nullable: true + maxLength: 255 + salary_range: + type: string + nullable: true + maxLength: 50 + job_type: + type: string + nullable: true + maxLength: 50 + minimum_karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + interest_groups: + type: string + maxLength: 255 + task: + type: string + nullable: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - company + - created_at + - domain + - id + - interest_groups + - recruiter + - title + - updated_at + LaunchpadLeaderBoard: + type: object + properties: + rank: + type: integer + full_name: + type: string + maxLength: 150 + actual_karma: + type: integer + karma: + type: integer + org: + type: string + nullable: true + district_name: + type: string + nullable: true + state: + type: string + nullable: true + launchpad_id: + allOf: + - $ref: '#/components/schemas/LaunchPadID' + readOnly: true + required: + - district_name + - full_name + - karma + - launchpad_id + - org + - rank + - state + LaunchpadListJobsResponse: + type: object + properties: + jobs: + type: array + items: + type: object + additionalProperties: {} + summary: + type: object + additionalProperties: {} + required: + - jobs + - summary + LaunchpadLoginCompanyResponse: + type: object + properties: + id: + type: string + name: + type: string + username: + type: string + poc_name: + type: string + nullable: true + poc_email: + type: string + nullable: true + created_at: + type: string + format: date-time + accessToken: + type: string + refreshToken: + type: string + required: + - accessToken + - created_at + - id + - name + - poc_email + - poc_name + - refreshToken + - username + LaunchpadLoginRecruiterResponse: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + phone: + type: string + nullable: true + role: + type: string + nullable: true + company_id: + type: string + created_at: + type: string + format: date-time + accessToken: + type: string + refreshToken: + type: string + required: + - accessToken + - company_id + - created_at + - email + - id + - name + - phone + - refreshToken + - role + LaunchpadParticipants: + type: object + properties: + full_name: + type: string + maxLength: 150 + level: + type: string + nullable: true + org: + type: string + nullable: true + district_name: + type: string + nullable: true + state: + type: string + nullable: true + required: + - district_name + - full_name + - level + - org + - state + LaunchpadRecruiter: + type: object + properties: + id: + type: string + maxLength: 36 + company: + type: string + name: + type: string + maxLength: 100 + email: + type: string + maxLength: 100 + phone: + type: string + maxLength: 20 + password: + type: string + maxLength: 255 + role: + type: string + nullable: true + maxLength: 50 + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - company + - created_at + - email + - id + - name + - password + - phone + - updated_at + LaunchpadRecruiterInfoResponse: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + phone: + type: string + nullable: true + role: + type: string + nullable: true + company_id: + type: string + company_name: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - company_id + - company_name + - created_at + - email + - id + - name + - phone + - role + - updated_at + LaunchpadRefreshTokenResponse: + type: object + properties: + accessToken: + type: string + refreshToken: + type: string + required: + - accessToken + - refreshToken + LaunchpadScheduleInterviewResponse: + type: object + properties: + application_id: + type: string + interview_date: + type: string + format: date + interview_platform: + type: string + nullable: true + interview_time: + type: string + format: time + nullable: true + status: + type: string + required: + - application_id + - interview_date + - interview_platform + - interview_time + - status + LaunchpadSendInvitationsResponse: + type: object + properties: + job_info: + type: object + additionalProperties: {} + invitation_summary: + type: object + additionalProperties: {} + invited_students: + type: array + items: + type: object + additionalProperties: {} + already_invited_students: + type: array + items: + type: object + additionalProperties: {} + required: + - already_invited_students + - invitation_summary + - invited_students + - job_info + LaunchpadStudentApplyResponse: + type: object + properties: + application_id: + type: string + job_info: + type: object + additionalProperties: {} + application_details: + type: object + additionalProperties: {} + status: + type: string + applied_at: + type: string + format: date-time + required: + - application_details + - application_id + - applied_at + - job_info + - status + LaunchpadStudentInvitationsResponse: + type: object + properties: + data: + type: array + items: + type: object + additionalProperties: {} + pagination: + type: object + additionalProperties: {} + required: + - data + - pagination + LaunchpadUpdateUser: + type: object + properties: + full_name: + type: string + nullable: true + maxLength: 255 + email: + type: string + maxLength: 255 + phone_number: + type: string + nullable: true + maxLength: 15 + role: + enum: + - IEEEAdmin + - IEEEDC + type: string + description: |- + * `IEEEAdmin` - IEEEAdmin + * `IEEEDC` - IEEEDC + x-spec-enum-id: 5b62ff0e8647fe73 + district: + type: string + nullable: true + maxLength: 100 + zone: + type: string + nullable: true + maxLength: 100 + remove_colleges: + type: array + items: + type: string + add_colleges: + type: array + items: + type: string + required: + - add_colleges + - email + - remove_colleges + - role + LaunchpadUser: + type: object + properties: + id: + type: string + readOnly: true + maxLength: 36 + full_name: + type: string + nullable: true + maxLength: 255 + email: + type: string + maxLength: 255 + phone_number: + type: string + nullable: true + maxLength: 15 + role: + enum: + - IEEEAdmin + - IEEEDC + type: string + description: |- + * `IEEEAdmin` - IEEEAdmin + * `IEEEDC` - IEEEDC + x-spec-enum-id: 5b62ff0e8647fe73 + district: + type: string + nullable: true + maxLength: 100 + zone: + type: string + nullable: true + maxLength: 100 + colleges: + type: array + items: + type: string + writeOnly: true + required: + - colleges + - email + - id + - role + LaunchpadUserList: + type: object + properties: + id: + type: string + maxLength: 36 + full_name: + type: string + nullable: true + maxLength: 255 + email: + type: string + maxLength: 255 + phone_number: + type: string + nullable: true + maxLength: 15 + role: + type: string + maxLength: 20 + district: + type: string + nullable: true + maxLength: 100 + zone: + type: string + nullable: true + maxLength: 100 + colleges: + type: string + readOnly: true + required: + - colleges + - email + - id + - role + LaunchpadVerifyResetTokenResponse: + type: object + properties: + valid: + type: boolean + user_name: + type: string + nullable: true + expires_at: + type: string + format: date-time + nullable: true + required: + - expires_at + - user_name + - valid + Leaderboard: + type: object + properties: + name: + type: string + maxLength: 100 + count: + type: integer + muid: + type: string + maxLength: 100 + required: + - count + - muid + - name + LeaderboardCollegeItem: + type: object + properties: + id: + type: string + code: + type: string + title: + type: string + total_students: + type: integer + total_karma: + type: integer + required: + - code + - id + - title + - total_karma + - total_students + LeaderboardCollegeMonthlyItem: + type: object + properties: + id: + type: string + code: + type: string + total_karma: + type: integer + students: + type: integer + required: + - code + - id + - students + - total_karma + LeaderboardStudentsMonthlyItem: + type: object + properties: + muid: + type: string + full_name: + type: string + total_karma: + type: integer + institution: + type: string + nullable: true + profile_pic: + type: string + nullable: true + required: + - full_name + - institution + - muid + - profile_pic + - total_karma + LearningCircleAttendeeReportGetResponse: + type: object + properties: + report: + type: string + nullable: true + description: Attendee's report text + report_link: + type: string + nullable: true + description: URL link to the attendee's report + required: + - report + - report_link + LearningCircleDetail: + type: object + properties: + id: + type: string + maxLength: 36 + ig: + type: string + readOnly: true + title: + type: string + maxLength: 100 + description: + type: string + nullable: true + maxLength: 500 + org: + type: string + readOnly: true + nullable: true + created_by: + type: string + readOnly: true + required: + - created_by + - ig + - org + - title + LearningCircleEnrollment: + type: object + properties: + full_name: + type: string + muid: + type: string + email: + type: string + dwms_id: + type: string + nullable: true + circle_name: + type: string + circle_ig: + type: string + organisation: + type: string + district: + type: string + nullable: true + karma_earned: + type: integer + required: + - circle_ig + - circle_name + - district + - dwms_id + - email + - full_name + - karma_earned + - muid + - organisation + LearningCircleMemberDetailsOwner: + type: object + properties: + id: + type: string + description: UUID of the circle creator + full_name: + type: string + description: Full name of the circle creator + profile_pic: + type: string + nullable: true + description: Profile picture URL of the circle creator + muid: + type: string + description: Unique mulearn ID of the circle creator + required: + - full_name + - id + - muid + - profile_pic + LearningCircleMemberDetailsResponse: + type: object + properties: + owner: + $ref: '#/components/schemas/LearningCircleMemberDetailsOwner' + members: + type: array + items: + $ref: '#/components/schemas/LearningCircleMemberItem' + description: Accepted members sorted by ig_karma descending + required: + - members + - owner + LearningCircleMemberItem: + type: object + properties: + id: + type: string + description: UUID of the member + full_name: + type: string + description: Full name of the member + profile_pic: + type: string + nullable: true + description: Profile picture URL of the member + muid: + type: string + description: Unique mulearn ID of the member + ig_karma: + type: integer + description: Total karma earned by the member in this circle's interest + group + is_leader: + type: boolean + description: Whether this member holds the lead role in the circle + required: + - full_name + - id + - ig_karma + - is_leader + - muid + - profile_pic + LearningCircleReportAttendeeItem: + type: object + properties: + user_id: + type: string + description: UUID of the attendee + full_name: + type: string + description: Full name of the attendee + muid: + type: string + description: Unique mulearn ID of the attendee + is_lc_approved: + type: boolean + nullable: true + description: Whether the organizer approved this attendee's participation + report: + type: string + nullable: true + description: Attendee's own report text + report_link: + type: string + nullable: true + description: URL to the attendee's report + required: + - full_name + - is_lc_approved + - muid + - report + - report_link + - user_id + LearningCircleReportGetResponse: + type: object + properties: + is_report_submitted: + type: boolean + description: Whether the meeting report has been submitted by the organizer + report: + type: string + nullable: true + description: Meeting report text submitted by the organizer + attendees: + type: array + items: + $ref: '#/components/schemas/LearningCircleReportAttendeeItem' + description: List of attendees who joined the meeting + required: + - attendees + - is_report_submitted + - report + LearningCircleUser: + type: object + properties: + id: + type: string + maxLength: 36 + muid: + type: string + maxLength: 100 + full_name: + type: string + maxLength: 150 + email: + type: string + format: email + maxLength: 200 + mobile: + type: string + nullable: true + maxLength: 15 + required: + - email + - full_name + - muid + LinkSocials: + type: object + properties: + github: + type: string + nullable: true + maxLength: 60 + facebook: + type: string + nullable: true + maxLength: 60 + instagram: + type: string + nullable: true + maxLength: 60 + linkedin: + type: string + nullable: true + maxLength: 60 + dribble: + type: string + nullable: true + maxLength: 60 + behance: + type: string + nullable: true + maxLength: 60 + stackoverflow: + type: string + nullable: true + maxLength: 60 + medium: + type: string + nullable: true + maxLength: 60 + hackerrank: + type: string + nullable: true + maxLength: 60 + Location: + type: object + properties: + id: + type: string + maxLength: 36 + location: + type: string + readOnly: true + required: + - location + LocationCountryListResponse: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + LocationStateListResponse: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + LocationZoneListResponse: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + MentorActivity: + type: object + properties: + id: + type: string + activity_type: + type: string + title: + type: string + description: + type: string + nullable: true + date: + type: string + format: date-time + status: + type: string + nullable: true + required: + - activity_type + - date + - id + - title + MentorAddParticipant: + type: object + properties: + muid: + type: string + required: + - muid + MentorDetail: + type: object + properties: + id: + type: string + maxLength: 36 + user: + type: string + user_full_name: + type: string + readOnly: true + user_email: + type: string + readOnly: true + about: + type: string + nullable: true + maxLength: 1000 + expertise: + type: string + nullable: true + reason: + type: string + nullable: true + maxLength: 1000 + hours: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + mentor_tier: + enum: + - IG_MENTOR + - MENTOR + - COMPANY_MENTOR + - CAMPUS_MENTOR + type: string + description: |- + * `IG_MENTOR` - IG Mentor + * `MENTOR` - Mentor + * `COMPANY_MENTOR` - Company Mentor + * `CAMPUS_MENTOR` - Campus Mentor + x-spec-enum-id: 502a7e5c2300b154 + status: + enum: + - PENDING + - APPROVED + - REJECTED + type: string + description: |- + * `PENDING` - Pending + * `APPROVED` - Approved + * `REJECTED` - Rejected + x-spec-enum-id: 1f00471a47c591be + preferred_ig_ids: + nullable: true + org: + type: string + nullable: true + verified_by: + type: string + nullable: true + verified_at: + type: string + format: date-time + nullable: true + company: + type: string + readOnly: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + required: + - company + - user + - user_email + - user_full_name + MentorIGDropdownResponse: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + MentorList: + type: object + properties: + id: + type: string + maxLength: 36 + user_id: + type: string + readOnly: true + user_full_name: + type: string + readOnly: true + user_email: + type: string + readOnly: true + muid: + type: string + readOnly: true + about: + type: string + nullable: true + maxLength: 1000 + expertise: + type: string + nullable: true + verification_note: + type: string + nullable: true + maxLength: 500 + verified_at: + type: string + format: date-time + nullable: true + mentor_tier: + enum: + - IG_MENTOR + - MENTOR + - COMPANY_MENTOR + - CAMPUS_MENTOR + type: string + description: |- + * `IG_MENTOR` - IG Mentor + * `MENTOR` - Mentor + * `COMPANY_MENTOR` - Company Mentor + * `CAMPUS_MENTOR` - Campus Mentor + x-spec-enum-id: 502a7e5c2300b154 + status: + enum: + - PENDING + - APPROVED + - REJECTED + type: string + description: |- + * `PENDING` - Pending + * `APPROVED` - Approved + * `REJECTED` - Rejected + x-spec-enum-id: 1f00471a47c591be + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + required: + - muid + - user_email + - user_full_name + - user_id + MentorOverviewData: + type: object + properties: + scopes: + type: array + items: + $ref: '#/components/schemas/MentorScopeMetrics' + required: + - scopes + MentorRegister: + type: object + properties: + about: + type: string + nullable: true + maxLength: 1000 + expertise: + type: string + nullable: true + reason: + type: string + nullable: true + maxLength: 1000 + hours: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + preferred_ig_ids: + nullable: true + MentorScopeGrant: + type: object + properties: + id: + type: string + maxLength: 36 + scope_type: + enum: + - COMPANY_MENTOR + - IG_MENTOR + - CAMPUS_MENTOR + - MENTOR + type: string + description: |- + * `COMPANY_MENTOR` - Company Mentor + * `IG_MENTOR` - IG Mentor + * `CAMPUS_MENTOR` - Campus Mentor + * `MENTOR` - Mentor + x-spec-enum-id: 332dbd6321b20f1c + scope_id: + type: string + nullable: true + maxLength: 36 + is_active: + type: boolean + granted_by_name: + type: string + readOnly: true + granted_at: + type: string + format: date-time + revoked_by_name: + type: string + readOnly: true + revoked_at: + type: string + format: date-time + nullable: true + required: + - granted_at + - granted_by_name + - revoked_by_name + - scope_type + MentorScopeMetrics: + type: object + properties: + scope_type: + type: string + scope_id: + type: string + scope_name: + type: string + nullable: true + metrics: + type: object + additionalProperties: {} + required: + - metrics + - scope_id + - scope_name + - scope_type + MentorTaskCreate: + type: object + description: |- + Mirrors admin TaskModifySerializer field-for-field. + Locked fields (active, approval_status, requested_by, requested_at) + are injected by the view via serializer.save(**overrides). + properties: + hashtag: + type: string + maxLength: 75 + title: + type: string + maxLength: 75 + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + usage_count: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: + type: string + nullable: true + type: + type: string + level: + type: string + nullable: true + ig: + type: string + created_by: + type: string + updated_by: + type: string + required: + - created_by + - hashtag + - ig + - title + - type + - updated_by + MentorTaskList: + type: object + description: Read-only list/detail serializer — mirrors admin TaskListSerializer. + properties: + id: + type: string + maxLength: 36 + hashtag: + type: string + maxLength: 75 + discord_link: + type: string + nullable: true + maxLength: 200 + title: + type: string + maxLength: 75 + description: + type: string + nullable: true + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + channel: + type: string + type: + type: string + active: + type: boolean + variable_karma: + type: boolean + usage_count: + type: integer + maximum: 2147483647 + minimum: -2147483648 + level: + type: string + org: + type: string + ig: + type: string + event: + type: string + nullable: true + maxLength: 50 + bonus_karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + bonus_time: + type: string + format: date-time + nullable: true + approval_status: + enum: + - approved + - pending + - rejected + type: string + description: |- + * `approved` - Approved + * `pending` - Pending + * `rejected` - Rejected + x-spec-enum-id: e9436f319d54eebe + rejection_reason: + type: string + nullable: true + reviewed_at: + type: string + format: date-time + nullable: true + requested_by_name: + type: string + requested_at: + type: string + format: date-time + nullable: true + skills: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - hashtag + - skills + - title + - type + - updated_at + MentorTaskListResponse: + type: object + properties: + data: + type: array + items: + type: object + additionalProperties: {} + pagination: + type: object + additionalProperties: {} + required: + - data + - pagination + MentorTaskUpdate: + type: object + description: |- + Partial update serializer — same writable fields as create. + The view injects approval_status / active / review-reset fields via save(). + properties: + hashtag: + type: string + maxLength: 75 + title: + type: string + maxLength: 75 + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + usage_count: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: + type: string + nullable: true + type: + type: string + level: + type: string + nullable: true + ig: + type: string + nullable: true + updated_by: + type: string + required: + - hashtag + - title + - type + - updated_by + MentorUpdate: + type: object + properties: + about: + type: string + nullable: true + maxLength: 1000 + expertise: + type: string + nullable: true + reason: + type: string + nullable: true + maxLength: 1000 + hours: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + preferred_ig_ids: + nullable: true + MentorshipSessionCalendar: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + mode: + enum: + - ONLINE + - OFFLINE + - HYBRID + type: string + description: |- + * `ONLINE` - Online + * `OFFLINE` - Offline + * `HYBRID` - Hybrid + x-spec-enum-id: af88be50a5259b14 + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + status: + enum: + - REQUESTED + - SCHEDULED + - PENDING_APPROVAL + - COMPLETED + - CANCELLED + - REJECTED + type: string + description: |- + * `REQUESTED` - Requested + * `SCHEDULED` - Scheduled + * `PENDING_APPROVAL` - Pending Approval + * `COMPLETED` - Completed + * `CANCELLED` - Cancelled + * `REJECTED` - Rejected + x-spec-enum-id: 64931a7c8f0e87bb + meeting_link: + type: string + nullable: true + maxLength: 500 + venue: + type: string + nullable: true + maxLength: 255 + mentor_name: + type: string + readOnly: true + mentee_count: + type: string + readOnly: true + required: + - ends_at + - mentee_count + - mentor_name + - starts_at + - status + - title + MinimalCampus: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 100 + org_type: + type: string + maxLength: 25 + required: + - org_type + - title + MinimalIG: + type: object + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + icon: + type: string + maxLength: 10 + required: + - icon + - name + MinimalUser: + type: object + properties: + id: + type: string + maxLength: 36 + full_name: + type: string + maxLength: 150 + muid: + type: string + maxLength: 100 + profile_pic: + type: string + readOnly: true + required: + - full_name + - muid + - profile_pic + MulearnerDirectory: + type: object + properties: + id: + type: string + maxLength: 36 + full_name: + type: string + maxLength: 150 + muid: + type: string + maxLength: 100 + email: + type: string + format: email + maxLength: 200 + karma: + type: string + readOnly: true + level: + type: string + readOnly: true + college: + type: string + readOnly: true + department: + type: string + readOnly: true + graduation_year: + type: string + readOnly: true + required: + - college + - department + - email + - full_name + - graduation_year + - karma + - level + - muid + MyEventInvite: + type: object + properties: + id: + type: string + maxLength: 36 + entity_type: + enum: + - user_ticket + - co_owner + - collab_ig + - collab_campus + - collab_campus_ig + - collab_company + type: string + description: |- + * `user_ticket` - User Ticket + * `co_owner` - Co-owner + * `collab_ig` - Collaborating IG + * `collab_campus` - Collaborating Campus + * `collab_campus_ig` - Collaborating Campus IG + * `collab_company` - Collaborating Company + x-spec-enum-id: 2799704cb5801b2d + entity_id: + type: string + maxLength: 36 + entity_detail: + type: string + readOnly: true + role_label: + type: string + nullable: true + maxLength: 100 + invite_status: + enum: + - pending + - accepted + - rejected + - '' + - null + type: string + description: |- + * `pending` - Pending + * `accepted` - Accepted + * `rejected` - Rejected + x-spec-enum-id: 4874309ac8d0a714 + nullable: true + rejection_reason: + type: string + nullable: true + maxLength: 500 + responded_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + readOnly: true + event_id: + type: string + readOnly: true + event_title: + type: string + readOnly: true + event_start_datetime: + type: string + format: date-time + readOnly: true + event_cover_image: + type: string + readOnly: true + required: + - created_at + - entity_detail + - entity_id + - entity_type + - event_cover_image + - event_id + - event_start_datetime + - event_title + Notification: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + title: + type: string + maxLength: 50 + description: + type: string + maxLength: 200 + button: + type: string + nullable: true + maxLength: 10 + url: + type: string + nullable: true + maxLength: 100 + created_at: + type: string + format: date-time + readOnly: true + user: + type: string + created_by: + type: string + required: + - created_at + - created_by + - description + - id + - title + - user + NotificationListResponse: + type: object + properties: + direct: + type: array + items: + $ref: '#/components/schemas/Notification' + broadcasts: + type: array + items: + $ref: '#/components/schemas/BroadcastNotification' + required: + - broadcasts + - direct + Order: + type: object + description: Serializer for one-time order creation requests + properties: + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + currency: + type: string + default: INR + name: + type: string + maxLength: 255 + donation_name: + type: string + maxLength: 100 + email: + type: string + format: email + phone_number: + type: string + maxLength: 20 + pan_number: + type: string + maxLength: 10 + address: + type: string + company: + type: string + maxLength: 255 + donation_type: + enum: + - one-time + - monthly + - yearly + type: string + description: |- + * `one-time` - one-time + * `monthly` - monthly + * `yearly` - yearly + x-spec-enum-id: a1444fc7968e746a + default: one-time + is_organisation: + type: boolean + default: false + required: + - amount + - email + - name + Org: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 100 + required: + - title + Organisation: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 100 + code: + type: string + maxLength: 12 + affiliation: + type: string + readOnly: true + district: + allOf: + - $ref: '#/components/schemas/District' + readOnly: true + required: + - affiliation + - code + - district + - title + OrganizationKarmaLogGetPostPatchDelete: + type: object + properties: + org: + type: string + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + type: + type: string + required: + - karma + - org + - type + OrganizationKarmaTypeGetPostPatchDelete: + type: object + properties: + title: + type: string + maxLength: 75 + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: + type: string + nullable: true + maxLength: 200 + required: + - karma + - title + OrganizationVerify: + type: object + properties: + verified: + type: boolean + org_id: + type: string + required: + - org_id + - verified + OrganizerInfo: + type: object + description: Reads organiser fields off the Event model directly. + properties: + organiser_type: + type: string + organiser_ig: + type: string + readOnly: true + organiser_campus: + type: string + readOnly: true + organiser_company: + type: string + readOnly: true + organiser_ci_id: + type: string + nullable: true + required: + - organiser_campus + - organiser_company + - organiser_ig + - organiser_type + PageDeleteResponse: + type: object + properties: + id: + type: string + required: + - id + PageOrderItem: + type: object + properties: + id: + type: string + page_number: + type: integer + required: + - id + - page_number + PageReorderRequest: + type: object + properties: + page_orders: + type: array + items: + $ref: '#/components/schemas/PageOrderItem' + required: + - page_orders + PageReorderResponse: + type: object + properties: + success: + type: boolean + required: + - success + ParticipantList: + type: object + properties: + id: + type: string + maxLength: 36 + session_id: + type: string + readOnly: true + user_id: + type: string + readOnly: true + user_full_name: + type: string + readOnly: true + mu_id: + type: string + readOnly: true + participant_role: + enum: + - MENTOR + - MENTEE + - CO_MENTOR + type: string + description: |- + * `MENTOR` - Mentor + * `MENTEE` - Mentee + * `CO_MENTOR` - Co-Mentor + x-spec-enum-id: 5532fb01fb167890 + attendance_status: + enum: + - INVITED + - ATTENDED + - ABSENT + type: string + description: |- + * `INVITED` - Invited + * `ATTENDED` - Attended + * `ABSENT` - Absent + x-spec-enum-id: 9b151332b9f464f1 + progress_note: + type: string + nullable: true + maxLength: 500 + feedback: + type: string + nullable: true + contributed_minutes: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + created_at: + type: string + format: date-time + readOnly: true + session_title: + type: string + readOnly: true + session_starts_at: + type: string + format: date-time + readOnly: true + session_ends_at: + type: string + format: date-time + readOnly: true + session_mode: + type: string + readOnly: true + session_meeting_link: + type: string + readOnly: true + session_venue: + type: string + readOnly: true + session_status: + type: string + readOnly: true + session_entity_id: + type: string + readOnly: true + session_entity_name: + type: string + readOnly: true + required: + - created_at + - mu_id + - participant_role + - session_ends_at + - session_entity_id + - session_entity_name + - session_id + - session_meeting_link + - session_mode + - session_starts_at + - session_status + - session_title + - session_venue + - user_full_name + - user_id + ParticipantUpdate: + type: object + properties: + attendance_status: + enum: + - INVITED + - ATTENDED + - ABSENT + type: string + description: |- + * `INVITED` - Invited + * `ATTENDED` - Attended + * `ABSENT` - Absent + x-spec-enum-id: 9b151332b9f464f1 + progress_note: + type: string + nullable: true + maxLength: 500 + contributed_minutes: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + PatchedAdminSessionVerify: + type: object + properties: + status: + enum: + - SCHEDULED + - REJECTED + - CANCELLED + type: string + description: |- + * `SCHEDULED` - SCHEDULED + * `REJECTED` - REJECTED + * `CANCELLED` - CANCELLED + x-spec-enum-id: 5bb964df4ed9f171 + apply_to_series: + type: boolean + default: false + PatchedApplicationTracking: + type: object + properties: + id: + type: string + readOnly: true + job: + type: string + readOnly: true + applicant_name: + type: string + readOnly: true + applicant_email: + type: string + readOnly: true + resume_link: + type: string + readOnly: true + nullable: true + cover_letter: + type: string + readOnly: true + nullable: true + status: + type: string + maxLength: 20 + rejection_reason: + type: string + nullable: true + applied_at: + type: string + format: date-time + readOnly: true + PatchedAvailabilitySlotCreateUpdate: + type: object + properties: + ig: + type: string + nullable: true + weekday: + type: integer + maximum: 32767 + minimum: -32768 + start_time: + type: string + format: time + end_time: + type: string + format: time + timezone: + type: string + maxLength: 64 + is_active: + type: boolean + valid_from: + type: string + format: date + nullable: true + valid_to: + type: string + format: date + nullable: true + PatchedChapterPage: + type: object + description: Serializer representing individual pages in a chapter. + properties: + id: + type: string + readOnly: true + chapter: + type: string + page_number: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + image_key: + type: string + maxLength: 255 + created_at: + type: string + format: date-time + readOnly: true + PatchedChapterWrite: + type: object + description: |- + Input serializer for POST /chapters/ and PATCH /chapters//. + Caller never directly specifies slug, status, published_at, or audit fields. + properties: + comic: + type: string + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + chapter_number: + type: string + format: decimal + pattern: ^-?\d{0,4}(?:\.\d{0,2})?$ + cover_image_key: + type: string + nullable: true + maxLength: 255 + PatchedCollegeChange: + type: object + properties: + org_id: + type: string + maxLength: 36 + department_id: + type: string + nullable: true + maxLength: 36 + PatchedComicWrite: + type: object + description: |- + Input serializer for POST /comics/ and PATCH /comics//. + The caller never sends: slug, status, *_count, published_at, or audit fields. + properties: + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + cover_image_key: + type: string + nullable: true + maxLength: 255 + PatchedCompanyTaskPatch: + type: object + description: |- + Validates the payload when a company updates an existing task. + All fields are optional to support partial updates. + properties: + title: + type: string + maxLength: 75 + hashtag: + type: string + maxLength: 75 + description: + type: string + nullable: true + karma: + type: integer + minimum: 1 + ig_id: + type: string + maxLength: 36 + type_id: + type: string + maxLength: 36 + channel_id: + type: string + nullable: true + maxLength: 36 + level_id: + type: string + nullable: true + maxLength: 36 + skill_ids: + type: array + items: + type: string + maxLength: 36 + description: List of active Skill UUIDs to link to this task. + PatchedCompanyUpdate: + type: object + properties: + name: + type: string + maxLength: 75 + logo: + type: string + nullable: true + description: + type: string + short_pitch: + type: string + nullable: true + maxLength: 900 + industry_sector: + type: string + nullable: true + maxLength: 75 + website_link: + type: string + nullable: true + email: + type: string + nullable: true + maxLength: 100 + location: + type: string + nullable: true + maxLength: 150 + district_id: + type: string + nullable: true + state_id: + type: string + nullable: true + country_id: + type: string + nullable: true + legal_name: + type: string + nullable: true + maxLength: 150 + registration_number: + type: string + nullable: true + maxLength: 100 + tax_id: + type: string + nullable: true + maxLength: 100 + company_size: + type: string + nullable: true + maxLength: 50 + linkedin_url: + type: string + nullable: true + founded_year: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + remote_policy: + type: string + nullable: true + maxLength: 20 + culture_text: + type: string + nullable: true + tech_stack: + nullable: true + perks: + nullable: true + testimonials: + nullable: true + gallery: + nullable: true + PatchedCompanyVerify: + type: object + properties: + status: + enum: + - verified + - rejected + type: string + description: |- + * `verified` - verified + * `rejected` - rejected + x-spec-enum-id: 3001a7a51be9bcf9 + rejection_reason: + type: string + PatchedContributorRoleUpdate: + type: object + description: Input for PATCH /comics/{comicId}/contributors/{contributorId}/. + properties: + role: + enum: + - writer + - artist + - colorist + - editor + type: string + description: |- + * `writer` - Writer + * `artist` - Artist + * `colorist` - Colorist + * `editor` - Editor + x-spec-enum-id: f103fc282a981e61 + PatchedEventTaskWrite: + type: object + description: Write serializer for creating/updating event-linked tasks. + properties: + hashtag: + type: string + maxLength: 75 + title: + type: string + maxLength: 75 + description: + type: string + nullable: true + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + type: + type: string + ig: + type: string + nullable: true + level: + type: string + nullable: true + org: + type: string + nullable: true + bonus_time: + type: string + format: date-time + nullable: true + PatchedGenreWrite: + type: object + description: |- + Input serializer for POST /comics/genres/ and PATCH /comics/genres//. + Caller only sends `name`; slug and audit fields are set internally. + properties: + name: + type: string + maxLength: 75 + PatchedJobUpdate: + type: object + properties: + title: + type: string + maxLength: 75 + experience: + type: string + nullable: true + maxLength: 20 + job_description: + type: string + nullable: true + location: + type: string + nullable: true + maxLength: 75 + salary_range: + type: string + nullable: true + maxLength: 36 + job_type: + type: string + maxLength: 20 + status: + type: string + maxLength: 20 + duration_value: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + duration_unit: + type: string + nullable: true + maxLength: 20 + hourly_rate: + type: string + format: decimal + pattern: ^-?\d{0,8}(?:\.\d{0,2})?$ + nullable: true + deliverables: + nullable: true + stipend: + type: string + nullable: true + maxLength: 75 + certificate_provided: + type: string + nullable: true + maxLength: 3 + rules: + type: array + items: + $ref: '#/components/schemas/JobRule' + PatchedMentorStudentRequestVerify: + type: object + description: |- + Allows a mentor to approve or reject a student's session request. + + On APPROVE: + - Optional overrides (starts_at, ends_at, mode, meeting_link, venue) are + applied so the mentor can adjust the proposed time / logistics. + - The session transitions from REQUESTED → PENDING_APPROVAL. + - created_by is updated to the approving mentor so the session appears in + their dashboard and they can edit/cancel it using existing APIs. + - requested_by is left untouched — permanent audit trail. + + On REJECT: + - The session transitions from REQUESTED → REJECTED. + - requested_by remains set; the student can view their rejected requests. + properties: + status: + enum: + - APPROVED + - REJECTED + type: string + description: |- + * `APPROVED` - APPROVED + * `REJECTED` - REJECTED + x-spec-enum-id: 00a806fd4640c2d2 + starts_at: + type: string + format: date-time + nullable: true + ends_at: + type: string + format: date-time + nullable: true + mode: + enum: + - ONLINE + - OFFLINE + - HYBRID + - null + type: string + description: |- + * `ONLINE` - Online + * `OFFLINE` - Offline + * `HYBRID` - Hybrid + x-spec-enum-id: af88be50a5259b14 + nullable: true + meeting_link: + type: string + nullable: true + venue: + type: string + nullable: true + PatchedMentorUpdate: + type: object + properties: + about: + type: string + nullable: true + maxLength: 1000 + expertise: + type: string + nullable: true + reason: + type: string + nullable: true + maxLength: 1000 + hours: + type: integer + maximum: 4294967295 + minimum: 0 + format: int64 + preferred_ig_ids: + nullable: true + PatchedMentorVerify: + type: object + properties: + status: + enum: + - APPROVED + - REJECTED + type: string + description: |- + * `APPROVED` - APPROVED + * `REJECTED` - REJECTED + x-spec-enum-id: 00a806fd4640c2d2 + verification_note: + type: string + PatchedParticipantFeedback: + type: object + properties: + feedback: + type: string + nullable: true + PatchedParticipantUpdate: + type: object + properties: + attendance_status: + enum: + - INVITED + - ATTENDED + - ABSENT + type: string + description: |- + * `INVITED` - Invited + * `ATTENDED` - Attended + * `ABSENT` - Absent + x-spec-enum-id: 9b151332b9f464f1 + progress_note: + type: string + nullable: true + maxLength: 500 + contributed_minutes: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + PatchedSessionUpdate: + type: object + properties: + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + mode: + enum: + - ONLINE + - OFFLINE + - HYBRID + type: string + description: |- + * `ONLINE` - Online + * `OFFLINE` - Offline + * `HYBRID` - Hybrid + x-spec-enum-id: af88be50a5259b14 + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + meeting_link: + type: string + nullable: true + maxLength: 500 + venue: + type: string + nullable: true + maxLength: 255 + max_participants: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + PatchedUserApplicationResubmit: + type: object + properties: + resume_link: + type: string + nullable: true + cover_letter: + type: string + nullable: true + ProfileBadgesResponse: + type: object + properties: + full_name: + type: string + completed_tasks: + type: array + items: + type: string + required: + - completed_tasks + - full_name + ProfileKarmaFeedResponse: + type: object + properties: + top_user: + $ref: '#/components/schemas/ProfileKarmaFeedTopUser' + top_college: + $ref: '#/components/schemas/ProfileKarmaFeedTopCollege' + required: + - top_college + - top_user + ProfileKarmaFeedTopCollege: + type: object + properties: + karma: + type: integer + name: + type: string + nullable: true + required: + - karma + - name + ProfileKarmaFeedTopUser: + type: object + properties: + karma: + type: integer + full_name: + type: string + nullable: true + muid: + type: string + nullable: true + required: + - full_name + - karma + - muid + ProfileUserPreferencesOrg: + type: object + properties: + id: + type: string + nullable: true + name: + type: string + nullable: true + required: + - id + - name + ProfileUserPreferencesResponse: + type: object + properties: + domains: + type: array + items: + type: string + endgoals: + type: array + items: + type: string + orgs: + type: array + items: + $ref: '#/components/schemas/ProfileUserPreferencesOrg' + required: + - domains + - endgoals + - orgs + Project: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 50 + description: + type: string + status: + enum: + - draft + - published + - archived + type: string + description: |- + * `draft` - Draft + * `published` - Published + * `archived` - Archived + x-spec-enum-id: 3999cf41a064d13a + logo: + type: string + format: uri + nullable: true + images: + type: array + items: + $ref: '#/components/schemas/ProjectImage' + readOnly: true + links: + type: array + items: + $ref: '#/components/schemas/ProjectLink' + readOnly: true + skills: + type: array + items: + $ref: '#/components/schemas/ProjectSkill' + readOnly: true + members: + type: array + items: + $ref: '#/components/schemas/ProjectMember' + readOnly: true + votes: + type: array + items: + $ref: '#/components/schemas/Vote' + readOnly: true + comments: + type: array + items: + $ref: '#/components/schemas/Comment' + readOnly: true + created_by: + type: string + readOnly: true + created_by_id: + type: string + readOnly: true + updated_by: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - comments + - created_at + - created_by + - created_by_id + - description + - images + - links + - members + - skills + - title + - updated_at + - updated_by + - votes + ProjectImage: + type: object + properties: + image: + type: string + format: uri + nullable: true + ProjectLink: + type: object + properties: + id: + type: string + maxLength: 36 + label: + type: string + maxLength: 50 + url: + type: string + maxLength: 500 + position: + type: integer + maximum: 2147483647 + minimum: -2147483648 + required: + - label + - url + ProjectMember: + type: object + properties: + id: + type: string + maxLength: 36 + is_linked: + type: string + readOnly: true + user_id: + type: string + readOnly: true + muid: + type: string + readOnly: true + full_name: + type: string + readOnly: true + profile_pic: + type: string + readOnly: true + external_name: + type: string + nullable: true + maxLength: 100 + role: + type: string + nullable: true + maxLength: 50 + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - full_name + - is_linked + - muid + - profile_pic + - user_id + ProjectSkill: + type: object + properties: + id: + type: string + name: + type: string + code: + type: string + icon: + type: string + required: + - code + - icon + - id + - name + ProjectUpdate: + type: object + description: |- + Accepts create / partial-update payload. Logo + images come as files; + links + skills come as JSON-encoded strings (because the request is + multipart/form-data). + properties: + title: + type: string + maxLength: 50 + description: + type: string + status: + enum: + - draft + - published + - archived + type: string + description: |- + * `draft` - Draft + * `published` - Published + * `archived` - Archived + x-spec-enum-id: 3999cf41a064d13a + logo: + type: string + format: uri + nullable: true + links_json: + type: string + writeOnly: true + skill_ids_json: + type: string + writeOnly: true + PublicCompanyProfile: + type: object + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + slug: + type: string + maxLength: 100 + logo: + type: string + nullable: true + description: + type: string + short_pitch: + type: string + nullable: true + maxLength: 900 + industry_sector: + type: string + nullable: true + maxLength: 75 + website_link: + type: string + nullable: true + email: + type: string + nullable: true + maxLength: 100 + location: + type: string + nullable: true + maxLength: 150 + district_name: + type: string + readOnly: true + state_name: + type: string + readOnly: true + country_name: + type: string + readOnly: true + company_size: + type: string + nullable: true + maxLength: 50 + linkedin_url: + type: string + nullable: true + founded_year: + type: integer + maximum: 65535 + minimum: 0 + nullable: true + remote_policy: + type: string + nullable: true + maxLength: 20 + culture_text: + type: string + nullable: true + tech_stack: + nullable: true + perks: + nullable: true + testimonials: + nullable: true + gallery: + nullable: true + required: + - country_name + - description + - district_name + - name + - slug + - state_name + QseverseConnectedUserDidsResponse: + type: object + properties: + dids: + type: array + items: + type: string + required: + - dids + QseverseConnectedUserItem: + type: object + properties: + did: + type: string + name: + type: string + nullable: true + email: + type: string + format: email + nullable: true + required: + - did + QseverseCredentialItem: + type: object + properties: + credentialId: + type: string + templateId: + type: string + issuedTo: + type: string + nullable: true + issuedAt: + type: string + nullable: true + status: + type: string + nullable: true + required: + - credentialId + - templateId + Referral: + type: object + properties: + muid: + type: string + user: + type: string + invite_code: + type: string + is_coin: + type: boolean + ReferralList: + type: object + properties: + id: + type: string + full_name: + type: string + muid: + type: string + karma: + type: string + readOnly: true + level: + type: string + readOnly: true + required: + - full_name + - id + - karma + - level + - muid + Register: + type: object + properties: + user: + $ref: '#/components/schemas/User' + integration: + $ref: '#/components/schemas/Integration' + referral: + $ref: '#/components/schemas/Referral' + required: + - user + RegisterCollegeItem: + type: object + properties: + id: + type: string + title: + type: string + required: + - id + - title + RegisterCollegesResponse: + type: object + properties: + colleges: + type: array + items: + $ref: '#/components/schemas/RegisterCollegeItem' + required: + - colleges + RegisterCompaniesResponse: + type: object + properties: + companies: + type: array + items: + $ref: '#/components/schemas/RegisterCompanyItem' + required: + - companies + RegisterCompanyItem: + type: object + properties: + id: + type: string + title: + type: string + required: + - id + - title + RegisterImagesRequest: + type: object + properties: + image_keys: + type: array + items: + type: string + required: + - image_keys + ResetPassword: + type: object + properties: + token: + type: string + new_password: + type: string + minLength: 8 + confirm_password: + type: string + minLength: 8 + user_type: + enum: + - company + - recruiter + type: string + description: |- + * `company` - Company + * `recruiter` - Recruiter + x-spec-enum-id: df59bea2861b6389 + required: + - confirm_password + - new_password + - token + - user_type + Role: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 75 + required: + - id + - title + RoleDropDown: + type: object + properties: + id: + type: string + maxLength: 36 + title: + type: string + maxLength: 75 + required: + - id + - title + SessionCreate: + type: object + properties: + id: + type: string + maxLength: 36 + entity_id: + type: string + nullable: true + maxLength: 36 + session_type: + enum: + - ig_session + - campus_session + - company_session + type: string + description: |- + * `ig_session` - IG Session + * `campus_session` - Campus Session + * `company_session` - Company Session + x-spec-enum-id: ab9317e33b7b40a3 + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + mode: + enum: + - ONLINE + - OFFLINE + - HYBRID + type: string + description: |- + * `ONLINE` - Online + * `OFFLINE` - Offline + * `HYBRID` - Hybrid + x-spec-enum-id: af88be50a5259b14 + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + meeting_link: + type: string + nullable: true + maxLength: 500 + venue: + type: string + nullable: true + maxLength: 255 + max_participants: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + is_recurring: + type: boolean + recurrence_type: + enum: + - DAILY + - WEEKLY + - MONTHLY + - '' + - null + type: string + description: |- + * `DAILY` - Daily + * `WEEKLY` - Weekly + * `MONTHLY` - Monthly + x-spec-enum-id: 91602d667f73d24e + nullable: true + recurrence_interval: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + recurrence_end_date: + type: string + format: date + nullable: true + child_session_ids: + type: string + readOnly: true + required: + - child_session_ids + - ends_at + - starts_at + - title + SessionList: + type: object + properties: + id: + type: string + maxLength: 36 + entity_id: + type: string + nullable: true + maxLength: 36 + entity_name: + type: string + readOnly: true + session_type: + enum: + - ig_session + - campus_session + - company_session + type: string + description: |- + * `ig_session` - IG Session + * `campus_session` - Campus Session + * `company_session` - Company Session + x-spec-enum-id: ab9317e33b7b40a3 + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + mode: + enum: + - ONLINE + - OFFLINE + - HYBRID + type: string + description: |- + * `ONLINE` - Online + * `OFFLINE` - Offline + * `HYBRID` - Hybrid + x-spec-enum-id: af88be50a5259b14 + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + status: + enum: + - REQUESTED + - SCHEDULED + - PENDING_APPROVAL + - COMPLETED + - CANCELLED + - REJECTED + type: string + description: |- + * `REQUESTED` - Requested + * `SCHEDULED` - Scheduled + * `PENDING_APPROVAL` - Pending Approval + * `COMPLETED` - Completed + * `CANCELLED` - Cancelled + * `REJECTED` - Rejected + x-spec-enum-id: 64931a7c8f0e87bb + created_by_id: + type: string + readOnly: true + created_by_name: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + max_participants: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + meeting_link: + type: string + nullable: true + maxLength: 500 + venue: + type: string + nullable: true + maxLength: 255 + is_recurring: + type: boolean + parent_session_id: + type: string + readOnly: true + nullable: true + recurrence_type: + enum: + - DAILY + - WEEKLY + - MONTHLY + - '' + - null + type: string + description: |- + * `DAILY` - Daily + * `WEEKLY` - Weekly + * `MONTHLY` - Monthly + x-spec-enum-id: 91602d667f73d24e + nullable: true + recurrence_interval: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + recurrence_end_date: + type: string + format: date + nullable: true + required: + - created_at + - created_by_id + - created_by_name + - ends_at + - entity_name + - parent_session_id + - starts_at + - status + - title + SessionUpdate: + type: object + properties: + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + mode: + enum: + - ONLINE + - OFFLINE + - HYBRID + type: string + description: |- + * `ONLINE` - Online + * `OFFLINE` - Offline + * `HYBRID` - Hybrid + x-spec-enum-id: af88be50a5259b14 + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + meeting_link: + type: string + nullable: true + maxLength: 500 + venue: + type: string + nullable: true + maxLength: 255 + max_participants: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + required: + - ends_at + - starts_at + - title + ShareUserProfileUpdate: + type: object + properties: + is_public: + type: boolean + updated_by: + type: string + updated_at: + type: string + Skill: + type: object + description: Full skill serializer for CRUD operations + properties: + id: + type: string + readOnly: true + name: + type: string + maxLength: 75 + code: + type: string + maxLength: 20 + description: + type: string + nullable: true + icon: + type: string + nullable: true + maxLength: 100 + is_active: + type: boolean + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - code + - created_at + - id + - name + - updated_at + SkillDropdown: + type: object + description: Minimal serializer for dropdown selections + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + code: + type: string + maxLength: 20 + required: + - code + - name + State: + type: object + properties: + name: + type: string + maxLength: 75 + country: + type: string + readOnly: true + required: + - country + - name + StudentInfo: + type: object + properties: + full_name: + type: string + muid: + type: string + circle_name: + type: string + circle_ig: + type: string + organisation: + type: string + dwms_id: + type: string + nullable: true + karma_earned: + type: integer + required: + - circle_ig + - circle_name + - dwms_id + - full_name + - karma_earned + - muid + - organisation + StudentLeaderboard: + type: object + properties: + muid: + type: string + maxLength: 100 + full_name: + type: string + total_karma: + type: integer + default: 0 + institution: + type: string + readOnly: true + profile_pic: + type: string + readOnly: true + required: + - full_name + - institution + - muid + - profile_pic + StudentSessionRequest: + type: object + description: |- + Used by students to create a session request. + + All sessions are Interest-Group scoped, so only ``ig_session`` requests are + accepted; company/campus session types are rejected. + + Validates: + - The session_type is ig_session and the student is a member of the target + Interest Group (entity_id). + - Time constraints (starts_at < ends_at, starts_at in future). + - Mode / venue / meeting-link consistency (same rules as SessionCreateSerializer). + - No duplicate pending request (same student + entity + title + starts_at). + properties: + id: + type: string + maxLength: 36 + session_type: + enum: + - ig_session + - campus_session + - company_session + type: string + description: |- + * `ig_session` - IG Session + * `campus_session` - Campus Session + * `company_session` - Company Session + x-spec-enum-id: ab9317e33b7b40a3 + entity_id: + type: string + nullable: true + maxLength: 36 + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + mode: + enum: + - ONLINE + - OFFLINE + - HYBRID + type: string + description: |- + * `ONLINE` - Online + * `OFFLINE` - Offline + * `HYBRID` - Hybrid + x-spec-enum-id: af88be50a5259b14 + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + meeting_link: + type: string + nullable: true + maxLength: 500 + venue: + type: string + nullable: true + maxLength: 255 + max_participants: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + required: + - ends_at + - starts_at + - title + StudentSessionRequestList: + type: object + description: |- + Read-only serializer used by mentors to see incoming student session requests. + Includes the requesting student's name for quick identification. + properties: + id: + type: string + maxLength: 36 + session_type: + enum: + - ig_session + - campus_session + - company_session + type: string + description: |- + * `ig_session` - IG Session + * `campus_session` - Campus Session + * `company_session` - Company Session + x-spec-enum-id: ab9317e33b7b40a3 + entity_id: + type: string + nullable: true + maxLength: 36 + entity_name: + type: string + readOnly: true + title: + type: string + maxLength: 150 + description: + type: string + nullable: true + mode: + enum: + - ONLINE + - OFFLINE + - HYBRID + type: string + description: |- + * `ONLINE` - Online + * `OFFLINE` - Offline + * `HYBRID` - Hybrid + x-spec-enum-id: af88be50a5259b14 + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + meeting_link: + type: string + nullable: true + maxLength: 500 + venue: + type: string + nullable: true + maxLength: 255 + max_participants: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + status: + enum: + - REQUESTED + - SCHEDULED + - PENDING_APPROVAL + - COMPLETED + - CANCELLED + - REJECTED + type: string + description: |- + * `REQUESTED` - Requested + * `SCHEDULED` - Scheduled + * `PENDING_APPROVAL` - Pending Approval + * `COMPLETED` - Completed + * `CANCELLED` - Cancelled + * `REJECTED` - Rejected + x-spec-enum-id: 64931a7c8f0e87bb + requested_by_id: + type: string + nullable: true + readOnly: true + requested_by_name: + type: string + readOnly: true + requested_by_muid: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - ends_at + - entity_name + - requested_by_id + - requested_by_muid + - requested_by_name + - starts_at + - status + - title + SubjectInfo: + type: object + properties: + type: + type: string + did: + type: string + full_name: + type: string + email: + type: string + format: email + phone: + type: string + required: + - did + - email + - full_name + - type + Subscription: + type: object + description: Serializer for subscription creation requests + properties: + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + currency: + type: string + default: INR + name: + type: string + maxLength: 255 + donation_name: + type: string + maxLength: 100 + email: + type: string + format: email + phone_number: + type: string + maxLength: 20 + pan_number: + type: string + maxLength: 10 + address: + type: string + company: + type: string + maxLength: 255 + donation_type: + enum: + - one-time + - monthly + - yearly + type: string + description: |- + * `one-time` - one-time + * `monthly` - monthly + * `yearly` - yearly + x-spec-enum-id: a1444fc7968e746a + is_organisation: + type: boolean + default: false + required: + - amount + - donation_type + - email + - name + TaskCompletedLeaderBoard: + type: object + properties: + muid: + type: string + maxLength: 100 + is_public: + type: boolean + default: false + rank: + type: integer + full_name: + type: string + maxLength: 150 + karma: + type: integer + org: + type: string + nullable: true + district_name: + type: string + nullable: true + state: + type: string + nullable: true + profile_pic: + type: string + readOnly: true + required: + - district_name + - full_name + - karma + - muid + - org + - profile_pic + - rank + - state + TaskListPublic: + type: object + properties: + id: + type: string + maxLength: 36 + hashtag: + type: string + maxLength: 75 + title: + type: string + maxLength: 75 + description: + type: string + nullable: true + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + channel: + type: string + discord_id: + type: string + type: + type: string + variable_karma: + type: boolean + level: + type: string + ig: + type: string + event: + type: string + nullable: true + maxLength: 50 + event_id: + type: string + nullable: true + required: + - hashtag + - title + - type + TaskPublicListResponse: + type: object + properties: + data: + type: array + items: + type: object + additionalProperties: {} + pagination: + type: object + additionalProperties: {} + required: + - data + - pagination + Top100LeaderboardItem: + type: object + properties: + id: + type: string + full_name: + type: string + profile_pic: + type: string + nullable: true + total_karma: + type: integer + nullable: true + org: + type: string + nullable: true + dis: + type: string + nullable: true + state: + type: string + nullable: true + time_: + type: string + format: date-time + nullable: true + title: 'Time ' + required: + - dis + - full_name + - id + - org + - profile_pic + - state + - time_ + - total_karma + UnverifiedOrganizationCreate: + type: object + properties: + title: + type: string + maxLength: 100 + org_type: + type: string + maxLength: 25 + graduation_year: + type: integer + nullable: true + department: + type: string + nullable: true + required: + - org_type + - title + UnverifiedOrganizations: + type: object + properties: + id: + type: string + readOnly: true + title: + type: string + maxLength: 100 + org_type: + type: string + maxLength: 25 + graduation_year: + type: integer + maximum: 2147483647 + minimum: -2147483648 + nullable: true + department: + type: string + readOnly: true + created_by: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - created_by + - department + - id + - org_type + - title + UploadURLResponse: + type: object + properties: + upload_url: + type: string + image_key: + type: string + required: + - image_key + - upload_url + User: + type: object + properties: + muid: + type: string + maxLength: 100 + full_name: + type: string + maxLength: 150 + email: + type: string + format: email + maxLength: 200 + mobile: + type: string + nullable: true + maxLength: 15 + gender: + enum: + - Male + - Female + - Other + - Prefer not to say + - '' + - null + type: string + description: |- + * `Male` - Male + * `Female` - Female + * `Other` - Other + * `Prefer not to say` - Prefer not to say + x-spec-enum-id: a804cfc7585be0eb + nullable: true + dob: + type: string + format: date + nullable: true + exist_in_guild: + type: boolean + joined: + type: string + roles: + type: string + readOnly: true + profile_pic: + type: string + readOnly: true + dynamic_type: + type: string + readOnly: true + user_domains: + type: string + readOnly: true + user_endgoals: + type: string + readOnly: true + interested_in_work: + type: boolean + interested_in_gig_work: + type: boolean + company: + type: string + readOnly: true + required: + - company + - dynamic_type + - email + - full_name + - joined + - muid + - profile_pic + - roles + - user_domains + - user_endgoals + UserAchievements: + type: object + properties: + id: + type: string + maxLength: 36 + user_id: + type: string + achievement: + allOf: + - $ref: '#/components/schemas/AchievementBasic' + readOnly: true + is_issued: + type: boolean + vc_url: + type: string + maxLength: 100 + required: + - achievement + - user_id + UserAppliedJobs: + type: object + properties: + id: + type: string + maxLength: 36 + job: + allOf: + - $ref: '#/components/schemas/JobList' + readOnly: true + resume_link: + type: string + nullable: true + cover_letter: + type: string + nullable: true + status: + type: string + maxLength: 20 + rejection_reason: + type: string + nullable: true + applied_at: + type: string + format: date-time + readOnly: true + required: + - applied_at + - job + UserBasicDetails: + type: object + properties: + id: + type: string + maxLength: 36 + full_name: + type: string + maxLength: 150 + muid: + type: string + maxLength: 100 + interest_groups: + type: string + readOnly: true + organizations: + type: string + readOnly: true + profile_pic: + type: string + readOnly: true + karma: + type: string + required: + - full_name + - interest_groups + - karma + - muid + - organizations + - profile_pic + UserCircleList: + type: object + description: Serializes a UserCircleLink entry for the 'My Circles' list. + properties: + circle_id: + type: string + readOnly: true + title: + type: string + readOnly: true + ig: + type: string + readOnly: true + org: + type: string + readOnly: true + nullable: true + is_lead: + type: boolean + readOnly: true + total_members: + type: string + readOnly: true + joined_at: + type: string + format: date-time + readOnly: true + required: + - circle_id + - ig + - is_lead + - joined_at + - org + - title + - total_members + UserCountry: + type: object + properties: + country_name: + type: string + required: + - country_name + UserDetail: + type: object + properties: + id: + type: string + maxLength: 36 + muid: + type: string + maxLength: 100 + email: + type: string + format: email + maxLength: 200 + role: + type: string + readOnly: true + full_name: + type: string + maxLength: 150 + required: + - email + - full_name + - muid + - role + UserIgEdit: + type: object + properties: + interest_group: + type: array + items: {} + writeOnly: true + required: + - interest_group + UserIgList: + type: object + properties: + id: + type: string + maxLength: 36 + name: + type: string + maxLength: 75 + required: + - name + UserLeaderboard: + type: object + properties: + full_name: + type: string + readOnly: true + karma: + type: integer + interest_groups: + type: string + readOnly: true + organizations: + type: string + readOnly: true + required: + - full_name + - interest_groups + - karma + - organizations + UserLevel: + type: object + properties: + name: + type: string + maxLength: 36 + tasks: + type: string + readOnly: true + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + required: + - karma + - name + - tasks + UserLog: + type: object + properties: + task_name: + type: string + readOnly: true + karma: + type: integer + maximum: 2147483647 + minimum: -2147483648 + created_date: + type: string + required: + - created_date + - karma + - task_name + UserOrgLink: + type: object + properties: + department: + type: string + nullable: true + graduation_year: + type: string + nullable: true + organization: + type: string + nullable: true + is_alumni: + type: boolean + is_student: + type: boolean + nullable: true + default: false + required: + - organization + UserPermute: + type: object + properties: + full_name: + type: string + user_domains: + type: string + readOnly: true + college_name: + type: string + readOnly: true + required: + - college_name + - full_name + - user_domains + UserProfile: + type: object + properties: + id: + type: string + maxLength: 36 + joined: + type: string + format: date-time + full_name: + type: string + maxLength: 150 + gender: + enum: + - Male + - Female + - Other + - Prefer not to say + - '' + - null + type: string + description: |- + * `Male` - Male + * `Female` - Female + * `Other` - Other + * `Prefer not to say` - Prefer not to say + x-spec-enum-id: a804cfc7585be0eb + nullable: true + muid: + type: string + maxLength: 100 + roles: + type: string + readOnly: true + role_verification: + type: string + readOnly: true + lead_enabler_verified: + type: string + readOnly: true + college_id: + type: string + readOnly: true + college_code: + type: string + readOnly: true + org_district_id: + type: string + readOnly: true + karma: + type: integer + rank: + type: string + readOnly: true + karma_distribution: + type: string + readOnly: true + level: + type: string + profile_pic: + type: string + readOnly: true + cover_pic: + type: string + readOnly: true + interest_groups: + type: string + readOnly: true + is_public: + type: boolean + percentile: + type: string + readOnly: true + required: + - college_code + - college_id + - cover_pic + - full_name + - interest_groups + - joined + - karma_distribution + - lead_enabler_verified + - muid + - org_district_id + - percentile + - profile_pic + - rank + - role_verification + - roles + UserProfileEdit: + type: object + properties: + full_name: + type: string + maxLength: 150 + email: + type: string + format: email + maxLength: 200 + mobile: + type: string + nullable: true + maxLength: 15 + communities: + type: array + items: {} + writeOnly: true + gender: + enum: + - Male + - Female + - Other + - Prefer not to say + - '' + - null + type: string + description: |- + * `Male` - Male + * `Female` - Female + * `Other` - Other + * `Prefer not to say` - Prefer not to say + x-spec-enum-id: a804cfc7585be0eb + nullable: true + dob: + type: string + format: date + nullable: true + district_id: + type: string + writeOnly: true + nullable: true + required: + - communities + - email + - full_name + UserRank: + type: object + properties: + full_name: + type: string + role: + type: string + readOnly: true + rank: + type: string + readOnly: true + karma: + type: string + readOnly: true + interest_groups: + type: string + readOnly: true + required: + - full_name + - interest_groups + - karma + - rank + - role + UserResetPasswordVerifyTokenResponse: + type: object + properties: + muid: + type: string + required: + - muid + UserShareQrcode: + type: object + properties: + profile_pic: + type: string + readOnly: true + required: + - profile_pic + UserState: + type: object + properties: + state_name: + type: string + required: + - state_name + UserTerm: + type: object + properties: + is_userterms_approved: + type: boolean + user: + type: string + required: + - user + UserZone: + type: object + properties: + zone_name: + type: string + required: + - zone_name + Vote: + type: object + properties: + id: + type: string + maxLength: 36 + vote: + enum: + - upvote + - downvote + type: string + description: |- + * `upvote` - Upvote + * `downvote` - Downvote + x-spec-enum-id: f2c471414c4c8578 + project: + type: string + user: + type: string + readOnly: true + user_id: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - project + - updated_at + - user + - user_id + - vote + WadhwaniAuthTokenResponse: + type: object + properties: + access_token: + type: string + token_type: + type: string + expires_in: + type: integer + scope: + type: string + required: + - access_token + - expires_in + - token_type + WadhwaniCollegeLeaderboard: + type: object + properties: + code: + type: string + title: + type: string + total_karma: + type: integer + students: + type: integer + required: + - code + - students + - title + - total_karma + WadhwaniCourseDetailsResponse: + type: object + properties: + status: + type: string + courses: + type: array + items: + $ref: '#/components/schemas/WadhwaniCourseItem' + required: + - status + WadhwaniCourseEnrollStatusResponse: + type: object + properties: + status: + type: string + enrolledCourses: + type: array + items: + $ref: '#/components/schemas/WadhwaniEnrolledCourseItem' + required: + - status + WadhwaniCourseItem: + type: object + properties: + courseRootId: + type: string + name: + type: string + description: + type: string + thumbnailUrl: + type: string + nullable: true + required: + - courseRootId + - name + WadhwaniCourseQuizDataResponse: + type: object + properties: + status: + type: string + quizData: + type: array + items: + $ref: '#/components/schemas/WadhwaniQuizItem' + required: + - status + WadhwaniEnrolledCourseItem: + type: object + properties: + courseRootId: + type: string + name: + type: string + enrollmentDate: + type: string + nullable: true + completionStatus: + type: string + nullable: true + required: + - courseRootId + - name + WadhwaniQuizItem: + type: object + properties: + quizId: + type: string + quizName: + type: string + score: + type: number + format: double + nullable: true + totalMarks: + type: number + format: double + nullable: true + attemptDate: + type: string + nullable: true + required: + - quizId + - quizName + WadhwaniUserLoginResponse: + type: object + properties: + status: + type: string + accessToken: + type: string + expiresIn: + type: integer + userCreated: + type: boolean + courseEnrolled: + type: boolean + redirectionUrl: + type: string + required: + - status + WadhwaniZoneLeaderboard: + type: object + properties: + zone_name: + type: string + total_karma: + type: integer + students: + type: integer + required: + - students + - total_karma + - zone_name + WeeklyKarma: + type: object + properties: + college_name: + type: string + readOnly: true + required: + - college_name + Zone: + type: object + properties: + name: + type: string + maxLength: 75 + state: + allOf: + - $ref: '#/components/schemas/State' + readOnly: true + required: + - name + - state + securitySchemes: + basicAuth: + type: http + scheme: basic + cookieAuth: + type: apiKey + in: cookie + name: sessionid + jwtAuth: + type: apiKey + in: header + name: Authorization + description: JWT Token Authentication. Enter **"Bearer "** diff --git a/requirements.txt b/requirements.txt index a154d6167..35f6063cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,4 +22,5 @@ pymysql==1.0.2 razorpay==1.4.2 reportlab==4.2.0 django_redis==5.4.0 -celery==5.4.0 \ No newline at end of file +celery==5.4.0 +drf-spectacular==0.27.2 \ No newline at end of file diff --git a/schema.sql b/schema.sql new file mode 100644 index 000000000..fdb6c80d7 --- /dev/null +++ b/schema.sql @@ -0,0 +1,2602 @@ +-- MySQL dump 10.13 Distrib 8.0.43, for macos15 (arm64) +-- +-- Host: mudb.clu5gyyjyzym.ap-south-1.rds.amazonaws.com Database: mu_dev +-- ------------------------------------------------------ +-- Server version 8.0.42 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; +SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN; +SET @@SESSION.SQL_LOG_BIN= 0; + +-- +-- GTID state at the beginning of the backup +-- + +SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ ''; + +-- +-- Table structure for table `achievement` +-- + +DROP TABLE IF EXISTS `achievement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `achievement` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `level_id` varchar(36) DEFAULT NULL, + `description` varchar(300) NOT NULL, + `icon` varchar(100) DEFAULT NULL, + `has_vc` tinyint(1) NOT NULL DEFAULT '0', + `tags` json NOT NULL, + `type` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `template_id` varchar(100) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + KEY `fk_achievement_ref_updated_by` (`updated_by`), + KEY `fk_achievement_ref_created_by` (`created_by`), + KEY `fk_achievement_ref_level` (`level_id`), + CONSTRAINT `fk_achievement_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_achievement_ref_level` FOREIGN KEY (`level_id`) REFERENCES `level` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_achievement_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `auth_group` +-- + +DROP TABLE IF EXISTS `auth_group`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `auth_group` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(150) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `auth_group_permissions` +-- + +DROP TABLE IF EXISTS `auth_group_permissions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `auth_group_permissions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `group_id` int NOT NULL, + `permission_id` int NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `auth_group_permissions_group_id_permission_id_0cd325b0_uniq` (`group_id`,`permission_id`), + KEY `auth_group_permissio_permission_id_84c5c92e_fk_auth_perm` (`permission_id`), + CONSTRAINT `auth_group_permissio_permission_id_84c5c92e_fk_auth_perm` FOREIGN KEY (`permission_id`) REFERENCES `auth_permission` (`id`), + CONSTRAINT `auth_group_permissions_group_id_b120cbf9_fk_auth_group_id` FOREIGN KEY (`group_id`) REFERENCES `auth_group` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `auth_permission` +-- + +DROP TABLE IF EXISTS `auth_permission`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `auth_permission` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `content_type_id` int NOT NULL, + `codename` varchar(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `auth_permission_content_type_id_codename_01ab375a_uniq` (`content_type_id`,`codename`), + CONSTRAINT `auth_permission_content_type_id_2f476e4b_fk_django_co` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `auth_user` +-- + +DROP TABLE IF EXISTS `auth_user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `auth_user` ( + `id` int NOT NULL AUTO_INCREMENT, + `password` varchar(128) NOT NULL, + `last_login` datetime(6) DEFAULT NULL, + `is_superuser` tinyint(1) NOT NULL, + `username` varchar(150) NOT NULL, + `first_name` varchar(150) NOT NULL, + `last_name` varchar(150) NOT NULL, + `email` varchar(254) NOT NULL, + `is_staff` tinyint(1) NOT NULL, + `is_active` tinyint(1) NOT NULL, + `date_joined` datetime(6) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `auth_user_groups` +-- + +DROP TABLE IF EXISTS `auth_user_groups`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `auth_user_groups` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `group_id` int NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `auth_user_groups_user_id_group_id_94350c0c_uniq` (`user_id`,`group_id`), + KEY `auth_user_groups_group_id_97559544_fk_auth_group_id` (`group_id`), + CONSTRAINT `auth_user_groups_group_id_97559544_fk_auth_group_id` FOREIGN KEY (`group_id`) REFERENCES `auth_group` (`id`), + CONSTRAINT `auth_user_groups_user_id_6a12ed8b_fk_auth_user_id` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `auth_user_user_permissions` +-- + +DROP TABLE IF EXISTS `auth_user_user_permissions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `auth_user_user_permissions` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL, + `permission_id` int NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `auth_user_user_permissions_user_id_permission_id_14a6b632_uniq` (`user_id`,`permission_id`), + KEY `auth_user_user_permi_permission_id_1fbb5f2c_fk_auth_perm` (`permission_id`), + CONSTRAINT `auth_user_user_permi_permission_id_1fbb5f2c_fk_auth_perm` FOREIGN KEY (`permission_id`) REFERENCES `auth_permission` (`id`), + CONSTRAINT `auth_user_user_permissions_user_id_a95ead1b_fk_auth_user_id` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `channel` +-- + +DROP TABLE IF EXISTS `channel`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `channel` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `discord_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + KEY `fk_channel_ref_updated_by` (`updated_by`), + KEY `fk_channel_ref_created_by` (`created_by`), + CONSTRAINT `fk_channel_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_channel_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `channel_backup` +-- + +DROP TABLE IF EXISTS `channel_backup`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `channel_backup` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `discord_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `circle_meet_attendees` +-- + +DROP TABLE IF EXISTS `circle_meet_attendees`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `circle_meet_attendees` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `meet_id` varchar(36) NOT NULL, + `is_joined` tinyint(1) NOT NULL DEFAULT '0', + `joined_at` datetime DEFAULT NULL, + `is_report_submitted` tinyint(1) NOT NULL DEFAULT '0', + `is_lc_approved` tinyint(1) NOT NULL DEFAULT '0', + `report_text` varchar(1000) DEFAULT NULL, + `report_link` varchar(200) DEFAULT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_circle_meet_attendees_ref_meet_id` (`meet_id`), + KEY `fk_circle_meet_attendees_ref_user_id` (`user_id`), + CONSTRAINT `fk_circle_meet_attendees_ref_meet_id` FOREIGN KEY (`meet_id`) REFERENCES `circle_meeting_log` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_circle_meet_attendees_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `circle_meeting_log` +-- + +DROP TABLE IF EXISTS `circle_meeting_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `circle_meeting_log` ( + `id` varchar(36) NOT NULL, + `circle_id` varchar(36) NOT NULL, + `meet_code` varchar(6) NOT NULL, + `title` varchar(100) NOT NULL, + `description` varchar(1000) NOT NULL, + `mode` varchar(10) NOT NULL, + `is_report_needed` tinyint(1) NOT NULL DEFAULT '1', + `report_description` varchar(1000) DEFAULT NULL, + `coord_x` float NOT NULL, + `coord_y` float NOT NULL, + `meet_place` varchar(255) NOT NULL, + `meet_link` varchar(100) DEFAULT NULL, + `meet_time` datetime NOT NULL, + `duration` int NOT NULL, + `is_report_submitted` tinyint(1) NOT NULL DEFAULT '0', + `is_approved` tinyint(1) NOT NULL DEFAULT '0', + `report_text` varchar(1000) DEFAULT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `is_recurring` tinyint(1) NOT NULL DEFAULT '0', + `recurrence_type` varchar(10) DEFAULT NULL, + `recurrence` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_circle_meeting_log_ref_circle_id` (`circle_id`), + KEY `fk_circle_meeting_log_ref_created_by` (`created_by`), + CONSTRAINT `fk_circle_meeting_log_ref_circle_id` FOREIGN KEY (`circle_id`) REFERENCES `learning_circle` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_circle_meeting_log_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `college` +-- + +DROP TABLE IF EXISTS `college`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `college` ( + `id` varchar(36) NOT NULL, + `level` int NOT NULL, + `org_id` varchar(36) NOT NULL, + `verified` tinyint(1) DEFAULT '0', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `lead_id` varchar(36) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_college_ref_org_id` (`org_id`), + KEY `fk_college_ref_created_by` (`created_by`), + KEY `fk_college_ref_updated_by` (`updated_by`), + KEY `fk_college_ref_lead` (`lead_id`), + CONSTRAINT `fk_college_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_college_ref_lead` FOREIGN KEY (`lead_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_college_ref_org_id` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_college_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `country` +-- + +DROP TABLE IF EXISTS `country`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `country` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_country_ref_updated_by` (`updated_by`), + KEY `fk_country_ref_created_by` (`created_by`), + CONSTRAINT `fk_country_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_country_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `department` +-- + +DROP TABLE IF EXISTS `department`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `department` ( + `id` varchar(36) NOT NULL, + `title` varchar(100) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_department_ref_updated_by` (`updated_by`), + KEY `fk_department_ref_created_by` (`created_by`), + CONSTRAINT `fk_department_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_department_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `device` +-- + +DROP TABLE IF EXISTS `device`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `device` ( + `id` varchar(36) NOT NULL, + `browser` varchar(36) NOT NULL, + `os` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `last_log_in` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_device_ref_user_id` (`user_id`), + CONSTRAINT `fk_device_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `district` +-- + +DROP TABLE IF EXISTS `district`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `district` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `zone_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_district_ref_zone_id` (`zone_id`), + KEY `fk_district_ref_updated_by` (`updated_by`), + KEY `fk_district_ref_created_by` (`created_by`), + CONSTRAINT `fk_district_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_district_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_district_ref_zone_id` FOREIGN KEY (`zone_id`) REFERENCES `zone` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `django_content_type` +-- + +DROP TABLE IF EXISTS `django_content_type`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `django_content_type` ( + `id` int NOT NULL AUTO_INCREMENT, + `app_label` varchar(100) NOT NULL, + `model` varchar(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `django_content_type_app_label_model_76bd3d3b_uniq` (`app_label`,`model`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `django_migrations` +-- + +DROP TABLE IF EXISTS `django_migrations`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `django_migrations` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `app` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `applied` datetime(6) NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `donation` +-- + +DROP TABLE IF EXISTS `donation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `donation` ( + `id` varchar(36) NOT NULL, + `donor_id` varchar(36) NOT NULL, + `order_id` varchar(100) DEFAULT NULL, + `payment_id` varchar(100) DEFAULT NULL, + `payment_method` varchar(50) DEFAULT NULL, + `amount` decimal(12,2) NOT NULL, + `currency` varchar(10) DEFAULT 'INR', + `donation_type` varchar(20) NOT NULL, + `is_paid` tinyint(1) DEFAULT '0', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `donation_name` varchar(100) DEFAULT NULL, + `payment_status` varchar(30) DEFAULT 'COMPLETED', + `reference_code` varchar(50) DEFAULT NULL, + `proof_url` text, + PRIMARY KEY (`id`), + KEY `idx_donation_donor_id` (`donor_id`), + KEY `idx_donation_order_id` (`order_id`), + KEY `idx_donation_reference_code` (`reference_code`), + KEY `idx_donation_payment_status` (`payment_status`), + CONSTRAINT `fk_donation_donor` FOREIGN KEY (`donor_id`) REFERENCES `donor` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `donor` +-- + +DROP TABLE IF EXISTS `donor`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `donor` ( + `id` varchar(36) NOT NULL, + `payment_id` varchar(100) NOT NULL, + `payment_method` varchar(100) NOT NULL, + `amount` float NOT NULL, + `currency` varchar(30) NOT NULL, + `name` varchar(100) NOT NULL, + `email` varchar(200) NOT NULL, + `company` varchar(100) DEFAULT NULL, + `phone_number` varchar(20) DEFAULT NULL, + `pan_number` varchar(10) DEFAULT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `address` text, + `is_organisation` tinyint(1) DEFAULT '0', + PRIMARY KEY (`id`), + KEY `fk_donor_ref_created_by` (`created_by`), + KEY `idx_donor_email` (`email`), + CONSTRAINT `fk_donor_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dynamic_role` +-- + +DROP TABLE IF EXISTS `dynamic_role`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dynamic_role` ( + `id` varchar(36) NOT NULL, + `type` varchar(50) NOT NULL, + `role` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_dynamic_role_ref_role_id` (`role`), + KEY `fk_role_management_ref_created_by` (`created_by`), + KEY `fk_role_management_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_dynamic_role_ref_role_id` FOREIGN KEY (`role`) REFERENCES `role` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_role_management_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_role_management_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `dynamic_user` +-- + +DROP TABLE IF EXISTS `dynamic_user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `dynamic_user` ( + `id` varchar(36) NOT NULL, + `type` varchar(50) NOT NULL, + `user_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_dynamic_user_ref_user_id` (`user_id`), + KEY `fk_dynamic_user_ref_created_by` (`created_by`), + KEY `fk_dynamic_user_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_dynamic_user_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_dynamic_user_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_dynamic_user_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `events` +-- + +DROP TABLE IF EXISTS `events`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `events` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `description` varchar(200) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_events_ref_updated_by` (`updated_by`), + KEY `fk_events_ref_created_by` (`created_by`), + CONSTRAINT `fk_events_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_events_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `forgot_password` +-- + +DROP TABLE IF EXISTS `forgot_password`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `forgot_password` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `expiry` datetime NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_forget_password_ref_user_id` (`user_id`), + CONSTRAINT `fk_forget_password_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `hackathon` +-- + +DROP TABLE IF EXISTS `hackathon`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `hackathon` ( + `id` varchar(36) NOT NULL, + `title` varchar(100) NOT NULL, + `tagline` varchar(150) DEFAULT NULL, + `description` varchar(5000) DEFAULT NULL, + `participant_count` int DEFAULT NULL, + `type` varchar(8) DEFAULT 'offline', + `website` varchar(200) DEFAULT NULL, + `org_id` varchar(36) DEFAULT NULL, + `district_id` varchar(36) DEFAULT NULL, + `place` varchar(255) DEFAULT NULL, + `event_logo` varchar(200) DEFAULT NULL, + `banner` varchar(200) DEFAULT NULL, + `is_open_to_all` tinyint(1) DEFAULT NULL, + `application_start` datetime DEFAULT NULL, + `application_ends` datetime DEFAULT NULL, + `event_start` datetime DEFAULT NULL, + `event_end` datetime DEFAULT NULL, + `status` varchar(20) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_hackathon_link_ref_org_id` (`org_id`), + KEY `fk_hackathon_link_ref_district_id` (`district_id`), + KEY `fk_hackathon_link_created_by` (`created_by`), + KEY `fk_hackathon_link_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_hackathon_link_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_link_ref_district_id` FOREIGN KEY (`district_id`) REFERENCES `district` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_link_ref_org_id` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_link_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `hackathon_form` +-- + +DROP TABLE IF EXISTS `hackathon_form`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `hackathon_form` ( + `id` varchar(36) NOT NULL, + `hackathon_id` varchar(36) NOT NULL, + `field_name` varchar(255) NOT NULL, + `field_type` varchar(50) NOT NULL, + `is_required` tinyint(1) NOT NULL DEFAULT '0', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_hackathon_form_ref_hackathon_id` (`hackathon_id`), + KEY `fk_hackathon_form_ref_created_by` (`created_by`), + KEY `fk_hackathon_form_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_hackathon_form_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_form_ref_hackathon_id` FOREIGN KEY (`hackathon_id`) REFERENCES `hackathon` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_form_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `hackathon_organiser_link` +-- + +DROP TABLE IF EXISTS `hackathon_organiser_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `hackathon_organiser_link` ( + `id` varchar(36) NOT NULL, + `organiser_id` varchar(36) NOT NULL, + `hackathon_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_hackathon_organiser_link_ref_organiser_id` (`organiser_id`), + KEY `fk_hackathon_organiser_link_ref_hackathon_id` (`hackathon_id`), + KEY `fk_hackathon_organiser_link_created_by` (`created_by`), + KEY `fk_hackathon_organiser_link_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_hackathon_organiser_link_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_organiser_link_ref_hackathon_id` FOREIGN KEY (`hackathon_id`) REFERENCES `hackathon` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_organiser_link_ref_organiser_id` FOREIGN KEY (`organiser_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_organiser_link_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `hackathon_submission` +-- + +DROP TABLE IF EXISTS `hackathon_submission`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `hackathon_submission` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `hackathon_id` varchar(36) NOT NULL, + `data` varchar(2000) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_hackathon_submission_ref_user_id` (`user_id`), + KEY `fk_hackathon_submission_ref_hackathon_id` (`hackathon_id`), + KEY `fk_hackathon_submission_ref_updated_by` (`updated_by`), + KEY `fk_hackathon_submission_ref_created_by` (`created_by`), + CONSTRAINT `fk_hackathon_submission_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_submission_ref_hackathon_id` FOREIGN KEY (`hackathon_id`) REFERENCES `hackathon` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_submission_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_hackathon_submission_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `integration` +-- + +DROP TABLE IF EXISTS `integration`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `integration` ( + `id` varchar(36) NOT NULL, + `name` varchar(255) NOT NULL, + `token` varchar(400) NOT NULL, + `auth_token` varchar(255) DEFAULT NULL, + `base_url` varchar(255) DEFAULT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `integration_authorization` +-- + +DROP TABLE IF EXISTS `integration_authorization`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `integration_authorization` ( + `id` varchar(36) NOT NULL, + `integration_id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `integration_value` varchar(255) NOT NULL, + `verified` tinyint(1) NOT NULL DEFAULT '0', + `updated_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + `additional_field` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `integration_value` (`integration_value`), + UNIQUE KEY `unique_integration_per_user_integration_id` (`integration_id`,`user_id`,`integration_value`), + KEY `fk_integration_authorization_user_id` (`user_id`), + CONSTRAINT `fk_integration_authorization_integration_id` FOREIGN KEY (`integration_id`) REFERENCES `integration` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_integration_authorization_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `interest_group` +-- + +DROP TABLE IF EXISTS `interest_group`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `interest_group` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `code` varchar(5) NOT NULL, + `icon` varchar(10) NOT NULL, + `category` varchar(20) NOT NULL DEFAULT 'others', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `about` text, + `prerequisites` text, + `resource` text, + `career_opportunities` text, + `top_blogs` text, + `people_to_follow` text, + `leads` text, + `mentors` text, + `thinktank` text, + `office_hours` varchar(200) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + UNIQUE KEY `code` (`code`), + KEY `fk_interest_group_ref_updated_by` (`updated_by`), + KEY `fk_interest_group_ref_created_by` (`created_by`), + CONSTRAINT `fk_interest_group_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_interest_group_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `intro_task_log` +-- + +DROP TABLE IF EXISTS `intro_task_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `intro_task_log` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `progress` int NOT NULL, + `channel_id` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_intro_task_log_ref_created_by` (`created_by`), + KEY `fk_intro_task_log_ref_updated_by` (`updated_by`), + KEY `fk_intro_task_log_ref_user_id` (`user_id`), + CONSTRAINT `fk_intro_task_log_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_intro_task_log_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_intro_task_log_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `karma_activity_log` +-- + +DROP TABLE IF EXISTS `karma_activity_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `karma_activity_log` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `karma` int NOT NULL DEFAULT '0', + `task_id` varchar(36) NOT NULL, + `task_message_id` varchar(36) DEFAULT NULL, + `lobby_message_id` varchar(36) DEFAULT NULL, + `dm_message_id` varchar(36) DEFAULT NULL, + `peer_approved` tinyint(1) DEFAULT NULL, + `peer_approved_by` varchar(36) DEFAULT NULL, + `appraiser_approved` tinyint(1) DEFAULT NULL, + `appraiser_approved_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_karma_activity_log_ref_user_id` (`user_id`), + KEY `fk_karma_activity_log_ref_task_id` (`task_id`), + KEY `fk_karma_activity_log_ref_updated_by` (`updated_by`), + KEY `fk_karma_activity_log_ref_created_by` (`created_by`), + CONSTRAINT `fk_karma_activity_log_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_karma_activity_log_ref_task_id` FOREIGN KEY (`task_id`) REFERENCES `task_list` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_karma_activity_log_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_karma_activity_log_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `launchpad` +-- + +DROP TABLE IF EXISTS `launchpad`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `launchpad` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `launchpad_id` varchar(100) NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `launchpad_id` (`launchpad_id`), + KEY `fk_launchpad_user_id` (`user_id`), + KEY `fk_launchpad_created_by` (`created_by`), + KEY `fk_launchpad_updated_by` (`updated_by`), + CONSTRAINT `fk_launchpad_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_launchpad_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_launchpad_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `launchpad_companies` +-- + +DROP TABLE IF EXISTS `launchpad_companies`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `launchpad_companies` ( + `id` varchar(36) NOT NULL, + `name` varchar(100) NOT NULL, + `website` varchar(200) DEFAULT NULL, + `description` text, + `address` varchar(255) DEFAULT NULL, + `poc_name` varchar(100) NOT NULL, + `poc_role` varchar(100) NOT NULL, + `poc_email` varchar(100) NOT NULL, + `poc_phone` varchar(20) NOT NULL, + `username` varchar(50) NOT NULL, + `password` varchar(255) NOT NULL, + `is_verified` tinyint(1) DEFAULT '0', + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `reset_token` varchar(100) DEFAULT NULL, + `reset_token_expires` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + UNIQUE KEY `username` (`username`), + KEY `idx_launchpad_companies_reset_token` (`reset_token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `launchpad_job_applications` +-- + +DROP TABLE IF EXISTS `launchpad_job_applications`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `launchpad_job_applications` ( + `id` varchar(36) NOT NULL, + `job_id` varchar(36) NOT NULL, + `student_id` varchar(36) NOT NULL, + `status` varchar(20) NOT NULL DEFAULT 'invited', + `resume_link` varchar(500) DEFAULT NULL, + `linkedin_link` varchar(500) DEFAULT NULL, + `portfolio_link` varchar(500) DEFAULT NULL, + `cover_letter` text, + `other_link` varchar(500) DEFAULT NULL, + `interview_date` datetime DEFAULT NULL, + `interview_time` time DEFAULT NULL, + `interview_platform` varchar(255) DEFAULT NULL, + `interview_link` varchar(500) DEFAULT NULL, + `interview_type` varchar(100) DEFAULT NULL, + `invited_at` datetime DEFAULT CURRENT_TIMESTAMP, + `applied_at` datetime DEFAULT NULL, + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_job_student` (`job_id`,`student_id`), + KEY `fk_student` (`student_id`), + CONSTRAINT `fk_job` FOREIGN KEY (`job_id`) REFERENCES `launchpad_jobs` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_student` FOREIGN KEY (`student_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `launchpad_job_tasks` +-- + +DROP TABLE IF EXISTS `launchpad_job_tasks`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `launchpad_job_tasks` ( + `id` varchar(36) NOT NULL, + `task_description` text NOT NULL, + `hashtags` varchar(255) DEFAULT NULL, + `is_verified` tinyint(1) NOT NULL DEFAULT '0', + `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `launchpad_jobs` +-- + +DROP TABLE IF EXISTS `launchpad_jobs`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `launchpad_jobs` ( + `id` varchar(36) NOT NULL, + `company_id` varchar(36) NOT NULL, + `recruiter_id` varchar(36) NOT NULL, + `title` varchar(100) NOT NULL, + `skills` varchar(255) DEFAULT NULL, + `experience` varchar(255) DEFAULT NULL, + `domain` varchar(255) NOT NULL, + `interest_groups` varchar(255) NOT NULL, + `task_description` text, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `opening_type` varchar(50) DEFAULT NULL, + `location` varchar(255) DEFAULT NULL, + `salary_range` varchar(50) DEFAULT NULL, + `job_type` varchar(50) DEFAULT NULL, + `minimum_karma` int DEFAULT '0', + `task_id` varchar(36) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_launchpad_jobs_company_id` (`company_id`), + KEY `fk_launchpad_jobs_recruiter_id` (`recruiter_id`), + KEY `fk_launchpad_jobs_task` (`task_id`), + CONSTRAINT `fk_launchpad_jobs_company_id` FOREIGN KEY (`company_id`) REFERENCES `launchpad_companies` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_launchpad_jobs_recruiter_id` FOREIGN KEY (`recruiter_id`) REFERENCES `launchpad_recruiters` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_launchpad_jobs_task` FOREIGN KEY (`task_id`) REFERENCES `launchpad_job_tasks` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `launchpad_recruiters` +-- + +DROP TABLE IF EXISTS `launchpad_recruiters`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `launchpad_recruiters` ( + `id` varchar(36) NOT NULL, + `company_id` varchar(36) NOT NULL, + `name` varchar(100) NOT NULL, + `email` varchar(100) NOT NULL, + `phone` varchar(20) NOT NULL, + `password` varchar(255) NOT NULL, + `role` varchar(50) DEFAULT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `reset_token` varchar(100) DEFAULT NULL, + `reset_token_expires` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`), + KEY `fk_launchpad_recruiters_company_id` (`company_id`), + KEY `idx_launchpad_recruiters_reset_token` (`reset_token`), + CONSTRAINT `fk_launchpad_recruiters_company_id` FOREIGN KEY (`company_id`) REFERENCES `launchpad_companies` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `launchpad_user` +-- + +DROP TABLE IF EXISTS `launchpad_user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `launchpad_user` ( + `id` varchar(36) NOT NULL, + `email` varchar(255) NOT NULL, + `phone_number` varchar(15) DEFAULT NULL, + `full_name` varchar(255) DEFAULT NULL, + `district` varchar(100) DEFAULT NULL, + `zone` varchar(100) DEFAULT NULL, + `role` varchar(20) NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `launchpad_user_college_link` +-- + +DROP TABLE IF EXISTS `launchpad_user_college_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `launchpad_user_college_link` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `college_id` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + `created_by_id` varchar(36) NOT NULL, + `updated_by_id` varchar(36) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_launchpad_user_college_link_user_id` (`user_id`), + KEY `fk_launchpad_user_college_link_college_id` (`college_id`), + KEY `fk_launchpad_user_college_link_created_by_id` (`created_by_id`), + KEY `fk_launchpad_user_college_link_updated_by_id` (`updated_by_id`), + CONSTRAINT `fk_launchpad_user_college_link_college_id` FOREIGN KEY (`college_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_launchpad_user_college_link_created_by_id` FOREIGN KEY (`created_by_id`) REFERENCES `launchpad_user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_launchpad_user_college_link_updated_by_id` FOREIGN KEY (`updated_by_id`) REFERENCES `launchpad_user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_launchpad_user_college_link_user_id` FOREIGN KEY (`user_id`) REFERENCES `launchpad_user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `learning_circle` +-- + +DROP TABLE IF EXISTS `learning_circle`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `learning_circle` ( + `id` varchar(36) NOT NULL, + `title` varchar(100) NOT NULL, + `description` varchar(1000) NOT NULL, + `name` varchar(255) DEFAULT NULL, + `circle_code` varchar(15) DEFAULT NULL, + `ig_id` varchar(36) NOT NULL, + `org_id` varchar(36) DEFAULT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `circle_code` (`circle_code`), + KEY `fk_learning_circle_ref_college_id` (`org_id`), + KEY `fk_learning_circle_ref_created_by` (`created_by`), + KEY `fk_learning_circle_ref_interest_group_id` (`ig_id`), + CONSTRAINT `fk_learning_circle_ref_college_id` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_learning_circle_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_learning_circle_ref_interest_group_id` FOREIGN KEY (`ig_id`) REFERENCES `interest_group` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `level` +-- + +DROP TABLE IF EXISTS `level`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `level` ( + `id` varchar(36) NOT NULL, + `level_order` int NOT NULL, + `name` varchar(36) NOT NULL, + `karma` int NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + KEY `fk_level_ref_created_by` (`created_by`), + KEY `fk_level_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_level_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_level_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `login_attempts_log` +-- + +DROP TABLE IF EXISTS `login_attempts_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `login_attempts_log` ( + `id` varchar(36) NOT NULL, + `email_muid` varchar(200) NOT NULL, + `status` varchar(36) NOT NULL, + `type` varchar(36) NOT NULL, + `ip_address` varchar(45) DEFAULT NULL, + `browser` varchar(255) DEFAULT NULL, + `os` varchar(255) DEFAULT NULL, + `version` varchar(255) DEFAULT NULL, + `device_type` varchar(255) DEFAULT NULL, + `city` varchar(36) DEFAULT NULL, + `region` varchar(36) DEFAULT NULL, + `country` varchar(36) DEFAULT NULL, + `location` varchar(36) DEFAULT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `mucoin_activity_log` +-- + +DROP TABLE IF EXISTS `mucoin_activity_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `mucoin_activity_log` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `coin` float NOT NULL, + `status` varchar(36) NOT NULL, + `task_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_mucoin_activity_log_ref_created_by` (`created_by`), + KEY `fk_mucoin_activity_log_ref_updated_by` (`updated_by`), + KEY `fk_mucoin_activity_log_ref_task_id` (`task_id`), + KEY `fk_mucoin_activity_log_ref_user_id` (`user_id`), + CONSTRAINT `fk_mucoin_activity_log_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mucoin_activity_log_ref_task_id` FOREIGN KEY (`task_id`) REFERENCES `task_list` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mucoin_activity_log_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mucoin_activity_log_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `mucoin_invite_log` +-- + +DROP TABLE IF EXISTS `mucoin_invite_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `mucoin_invite_log` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `email` varchar(200) NOT NULL, + `invite_code` varchar(36) NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_mucoin_invite_log_ref_user_id` (`user_id`), + KEY `fk_mucoin_invite_log_created_by` (`created_by`), + CONSTRAINT `fk_mucoin_invite_log_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mucoin_invite_log_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `notification` +-- + +DROP TABLE IF EXISTS `notification`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `notification` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `title` varchar(50) NOT NULL, + `description` varchar(200) NOT NULL, + `button` varchar(10) DEFAULT NULL, + `url` varchar(100) DEFAULT NULL, + `created_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_notification_ref_user_id` (`user_id`), + KEY `fk_notification_ref_created_by` (`created_by`), + CONSTRAINT `fk_notification_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_notification_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `org_affiliation` +-- + +DROP TABLE IF EXISTS `org_affiliation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `org_affiliation` ( + `id` varchar(36) NOT NULL, + `title` varchar(75) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_org_affiliation_ref_updated_by` (`updated_by`), + KEY `fk_org_affiliation_ref_created_by` (`created_by`), + CONSTRAINT `fk_org_affiliation_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_org_affiliation_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `org_discord_link` +-- + +DROP TABLE IF EXISTS `org_discord_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `org_discord_link` ( + `id` varchar(36) NOT NULL, + `discord_id` varchar(36) NOT NULL, + `org_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `discord_id` (`discord_id`), + UNIQUE KEY `org_id` (`org_id`), + KEY `fk_org_discord_link_created_by` (`created_by`), + KEY `fk_org_discord_link_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_college_discord_link_ref_org_id` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_org_discord_link_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_org_discord_link_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `org_karma_log` +-- + +DROP TABLE IF EXISTS `org_karma_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `org_karma_log` ( + `id` varchar(36) NOT NULL, + `org_id` varchar(36) NOT NULL, + `karma` int NOT NULL DEFAULT '0', + `type` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_org_karma_log_ref_org_id` (`org_id`), + KEY `fk_org_karma_log_ref_type` (`type`), + KEY `fk_org_karma_log_ref_created_by` (`created_by`), + KEY `fk_org_karma_log_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_org_karma_log_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_org_karma_log_ref_org_id` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_org_karma_log_ref_type` FOREIGN KEY (`type`) REFERENCES `org_karma_type` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_org_karma_log_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `org_karma_type` +-- + +DROP TABLE IF EXISTS `org_karma_type`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `org_karma_type` ( + `id` varchar(36) NOT NULL, + `title` varchar(75) NOT NULL, + `karma` int NOT NULL DEFAULT '0', + `description` varchar(200) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_org_karma_type_ref_updated_by` (`updated_by`), + KEY `fk_org_karma_type_ref_created_by` (`created_by`), + CONSTRAINT `fk_org_karma_type_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_org_karma_type_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `organization` +-- + +DROP TABLE IF EXISTS `organization`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `organization` ( + `id` varchar(36) NOT NULL, + `title` varchar(100) NOT NULL, + `code` varchar(12) NOT NULL, + `org_type` varchar(25) NOT NULL, + `affiliation_id` varchar(36) DEFAULT NULL, + `district_id` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `code` (`code`), + KEY `fk_organization_ref_affiliation_id` (`affiliation_id`), + KEY `fk_organization_ref_district_id` (`district_id`), + KEY `fk_organization_ref_updated_by` (`updated_by`), + KEY `fk_organization_ref_created_by` (`created_by`), + CONSTRAINT `fk_organization_ref_affiliation_id` FOREIGN KEY (`affiliation_id`) REFERENCES `org_affiliation` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_organization_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_organization_ref_district_id` FOREIGN KEY (`district_id`) REFERENCES `district` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_organization_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `orgbot_channel` +-- + +DROP TABLE IF EXISTS `orgbot_channel`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `orgbot_channel` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `discord_id` varchar(36) NOT NULL, + `org_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_orgbot_channel_ref_org_id` (`org_id`), + KEY `fk_orgbot_channel_ref_updated_by` (`updated_by`), + KEY `fk_orgbot_channel_ref_created_by` (`created_by`), + CONSTRAINT `fk_orgbot_channel_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_orgbot_channel_ref_org_id` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_orgbot_channel_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `orgbot_karma_log` +-- + +DROP TABLE IF EXISTS `orgbot_karma_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `orgbot_karma_log` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `karma` int NOT NULL DEFAULT '0', + `task_id` varchar(36) NOT NULL, + `task_message_id` varchar(36) DEFAULT NULL, + `lobby_message_id` varchar(36) DEFAULT NULL, + `dm_message_id` varchar(36) DEFAULT NULL, + `peer_approved` tinyint(1) DEFAULT NULL, + `peer_approved_by` varchar(36) DEFAULT NULL, + `appraiser_approved` tinyint(1) DEFAULT NULL, + `appraiser_approved_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_orgbot_karma_log_ref_user_id` (`user_id`), + KEY `fk_orgbot_karma_log_ref_task_id` (`task_id`), + KEY `fk_orgbot_karma_log_ref_updated_by` (`updated_by`), + KEY `fk_orgbot_karma_log_ref_created_by` (`created_by`), + CONSTRAINT `fk_orgbot_karma_log_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_orgbot_karma_log_ref_task_id` FOREIGN KEY (`task_id`) REFERENCES `orgbot_tasks` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_orgbot_karma_log_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_orgbot_karma_log_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `orgbot_tasks` +-- + +DROP TABLE IF EXISTS `orgbot_tasks`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `orgbot_tasks` ( + `id` varchar(36) NOT NULL, + `title` varchar(75) NOT NULL, + `hashtag` varchar(75) NOT NULL, + `description` varchar(200) NOT NULL, + `org_id` varchar(36) NOT NULL, + `karma` int DEFAULT NULL, + `usage_count` int DEFAULT NULL, + `level_order` int DEFAULT NULL, + `channel_id` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_orgbot_tasks_ref_updated_by` (`updated_by`), + KEY `fk_orgbot_tasks_ref_created_by` (`created_by`), + KEY `fk_orgbot_tasks_ref_channel_id` (`channel_id`), + CONSTRAINT `fk_orgbot_tasks_ref_channel_id` FOREIGN KEY (`channel_id`) REFERENCES `orgbot_channel` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_orgbot_tasks_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_orgbot_tasks_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `otp_verification` +-- + +DROP TABLE IF EXISTS `otp_verification`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `otp_verification` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `otp` int NOT NULL, + `expiry` datetime NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_otp_verification_ref_user_id` (`user_id`), + CONSTRAINT `fk_otp_verification_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `quiz` +-- + +DROP TABLE IF EXISTS `quiz`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `quiz` ( + `id` varchar(36) NOT NULL, + `name` varchar(255) NOT NULL, + `description` text, + `pass_rate` int DEFAULT '70', + `ordered` tinyint(1) DEFAULT '0', + `name_long` text, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `quiz_answers` +-- + +DROP TABLE IF EXISTS `quiz_answers`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `quiz_answers` ( + `id` varchar(36) NOT NULL, + `question_id` char(36) NOT NULL, + `answer` text NOT NULL, + `is_correct` tinyint(1) DEFAULT '0', + PRIMARY KEY (`id`), + KEY `question_id` (`question_id`), + CONSTRAINT `quiz_answers_ibfk_1` FOREIGN KEY (`question_id`) REFERENCES `quiz_questions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `quiz_log` +-- + +DROP TABLE IF EXISTS `quiz_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `quiz_log` ( + `id` varchar(36) NOT NULL, + `discord_id` varchar(32) NOT NULL, + `quiz_id` char(36) NOT NULL, + `attempt` int DEFAULT '1', + `passed` tinyint(1) DEFAULT '0', + `score` int DEFAULT NULL, + `attempted_on` datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `quiz_id` (`quiz_id`), + CONSTRAINT `quiz_log_ibfk_1` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `quiz_questions` +-- + +DROP TABLE IF EXISTS `quiz_questions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `quiz_questions` ( + `id` varchar(36) NOT NULL, + `quiz_id` char(36) NOT NULL, + `question` text NOT NULL, + `order_num` int DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `quiz_id` (`quiz_id`), + CONSTRAINT `quiz_questions_ibfk_1` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `quiz_sessions` +-- + +DROP TABLE IF EXISTS `quiz_sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `quiz_sessions` ( + `id` varchar(36) NOT NULL, + `discord_id` varchar(32) NOT NULL, + `channel_id` varchar(32) NOT NULL, + `current_question` char(36) DEFAULT NULL, + `active` tinyint(1) DEFAULT '1', + `passed` tinyint(1) DEFAULT '0', + `quiz_id` char(36) NOT NULL, + PRIMARY KEY (`id`), + KEY `quiz_id` (`quiz_id`), + CONSTRAINT `quiz_sessions_ibfk_1` FOREIGN KEY (`quiz_id`) REFERENCES `quiz` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `role` +-- + +DROP TABLE IF EXISTS `role`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `role` ( + `id` varchar(36) NOT NULL, + `title` varchar(75) NOT NULL, + `description` varchar(300) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `title` (`title`), + KEY `fk_role_ref_updated_by` (`updated_by`), + KEY `fk_role_ref_created_by` (`created_by`), + CONSTRAINT `fk_role_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_role_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `socials` +-- + +DROP TABLE IF EXISTS `socials`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `socials` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `github` varchar(60) DEFAULT NULL, + `facebook` varchar(60) DEFAULT NULL, + `instagram` varchar(60) DEFAULT NULL, + `linkedin` varchar(60) DEFAULT NULL, + `dribble` varchar(60) DEFAULT NULL, + `behance` varchar(60) DEFAULT NULL, + `stackoverflow` varchar(60) DEFAULT NULL, + `medium` varchar(60) DEFAULT NULL, + `hackerrank` varchar(60) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_socials_ref_user_id` (`user_id`), + KEY `fk_socials_ref_created_by` (`created_by`), + KEY `fk_socials_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_socials_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_socials_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_socials_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `state` +-- + +DROP TABLE IF EXISTS `state`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `state` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `country_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_state_ref_country_id` (`country_id`), + KEY `fk_state_ref_updated_by` (`updated_by`), + KEY `fk_state_ref_created_by` (`created_by`), + CONSTRAINT `fk_state_ref_country_id` FOREIGN KEY (`country_id`) REFERENCES `country` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_state_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_state_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `system_setting` +-- + +DROP TABLE IF EXISTS `system_setting`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `system_setting` ( + `key` varchar(100) NOT NULL, + `value` varchar(100) NOT NULL, + `updated_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `task_list` +-- + +DROP TABLE IF EXISTS `task_list`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `task_list` ( + `id` varchar(36) NOT NULL, + `hashtag` varchar(75) NOT NULL, + `discord_link` varchar(200) DEFAULT NULL, + `title` varchar(75) NOT NULL, + `description` text, + `karma` int DEFAULT NULL, + `channel_id` varchar(36) DEFAULT NULL, + `type_id` varchar(36) NOT NULL, + `org_id` varchar(36) DEFAULT NULL, + `level_id` varchar(36) DEFAULT NULL, + `ig_id` varchar(36) DEFAULT NULL, + `active` tinyint(1) NOT NULL DEFAULT '1', + `variable_karma` tinyint(1) NOT NULL DEFAULT '0', + `usage_count` int DEFAULT '1', + `event` varchar(50) DEFAULT NULL, + `bonus_time` datetime DEFAULT NULL, + `bonus_karma` int DEFAULT '0', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_task_list_ref_level_id` (`level_id`), + KEY `fk_task_list_ref_ig_id` (`ig_id`), + KEY `fk_task_list_ref_channel_id` (`channel_id`), + KEY `fk_task_list_ref_type_id` (`type_id`), + KEY `fk_task_list_ref_org_id` (`org_id`), + KEY `fk_task_list_ref_updated_by` (`updated_by`), + KEY `fk_task_list_ref_created_by` (`created_by`), + CONSTRAINT `fk_task_list_ref_channel_id` FOREIGN KEY (`channel_id`) REFERENCES `channel` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_list_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_list_ref_ig_id` FOREIGN KEY (`ig_id`) REFERENCES `interest_group` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_list_ref_level_id` FOREIGN KEY (`level_id`) REFERENCES `level` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_list_ref_org_id` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_list_ref_type_id` FOREIGN KEY (`type_id`) REFERENCES `task_type` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_list_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `task_type` +-- + +DROP TABLE IF EXISTS `task_type`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `task_type` ( + `id` varchar(36) NOT NULL, + `title` varchar(75) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_task_type_ref_updated_by` (`updated_by`), + KEY `fk_task_type_ref_created_by` (`created_by`), + CONSTRAINT `fk_task_type_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_task_type_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `unverified_organization` +-- + +DROP TABLE IF EXISTS `unverified_organization`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `unverified_organization` ( + `id` varchar(36) NOT NULL, + `title` varchar(100) NOT NULL, + `org_type` varchar(25) NOT NULL, + `graduation_year` int DEFAULT NULL, + `department_id` varchar(36) DEFAULT NULL, + `verified` tinyint(1) DEFAULT NULL, + `verified_by` varchar(36) DEFAULT NULL, + `verified_at` datetime DEFAULT NULL, + `org_id` varchar(36) DEFAULT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_unverified_organizations_verified_by_user` (`verified_by`), + KEY `fk_unverified_organizations_org_id_organization` (`org_id`), + KEY `fk_unverified_organizations_department_id_department` (`department_id`), + KEY `fk_unverified_organizations_created_by_user` (`created_by`), + CONSTRAINT `fk_unverified_organizations_created_by_user` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_unverified_organizations_department_id_department` FOREIGN KEY (`department_id`) REFERENCES `department` (`id`), + CONSTRAINT `fk_unverified_organizations_org_id_organization` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`), + CONSTRAINT `fk_unverified_organizations_verified_by_user` FOREIGN KEY (`verified_by`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `url_shortener` +-- + +DROP TABLE IF EXISTS `url_shortener`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `url_shortener` ( + `id` varchar(36) NOT NULL, + `title` varchar(100) NOT NULL, + `short_url` varchar(100) NOT NULL, + `long_url` varchar(500) NOT NULL, + `count` int NOT NULL DEFAULT '0', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `short_url` (`short_url`), + KEY `fk_url_shorten_ref_updated_by` (`updated_by`), + KEY `fk_url_shorten_ref_created_by` (`created_by`), + CONSTRAINT `fk_url_shorten_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_url_shorten_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `url_shortener_tracker` +-- + +DROP TABLE IF EXISTS `url_shortener_tracker`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `url_shortener_tracker` ( + `id` varchar(36) NOT NULL, + `url_shortener_id` varchar(36) DEFAULT NULL, + `ip_address` varchar(45) DEFAULT NULL, + `browser` varchar(255) DEFAULT NULL, + `operating_system` varchar(255) DEFAULT NULL, + `version` varchar(255) DEFAULT NULL, + `device_type` varchar(255) DEFAULT NULL, + `city` varchar(36) DEFAULT NULL, + `region` varchar(36) DEFAULT NULL, + `country` varchar(36) DEFAULT NULL, + `location` varchar(36) DEFAULT NULL, + `referrer` varchar(36) DEFAULT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_url_shortener_tracker_ref_url_shortener_id` (`url_shortener_id`), + CONSTRAINT `fk_url_shortener_tracker_ref_url_shortener_id` FOREIGN KEY (`url_shortener_id`) REFERENCES `url_shortener` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user` +-- + +DROP TABLE IF EXISTS `user`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user` ( + `id` varchar(36) NOT NULL, + `full_name` varchar(150) NOT NULL, + `discord_id` varchar(36) DEFAULT NULL, + `muid` varchar(100) NOT NULL, + `email` varchar(200) NOT NULL, + `password` varchar(200) DEFAULT NULL, + `mobile` varchar(15) DEFAULT NULL, + `gender` varchar(10) DEFAULT NULL, + `dob` date DEFAULT NULL, + `admin` tinyint(1) NOT NULL DEFAULT '0', + `exist_in_guild` tinyint(1) NOT NULL DEFAULT '0', + `created_at` datetime NOT NULL, + `district_id` varchar(36) DEFAULT NULL, + `deleted_at` datetime DEFAULT NULL, + `deleted_by` varchar(36) DEFAULT NULL, + `suspended_at` datetime DEFAULT NULL, + `suspended_by` varchar(36) DEFAULT NULL, + `interested_in_work` tinyint(1) DEFAULT '0', + `interested_in_gig_work` tinyint(1) DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `muid` (`muid`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `discord_id` (`discord_id`), + KEY `fk_user_ref_district_id` (`district_id`), + KEY `fk_user_ref_deleted_by` (`deleted_by`), + KEY `fk_user_ref_suspended_by` (`suspended_by`), + CONSTRAINT `fk_user_ref_deleted_by` FOREIGN KEY (`deleted_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_ref_district_id` FOREIGN KEY (`district_id`) REFERENCES `district` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_ref_suspended_by` FOREIGN KEY (`suspended_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_achievements_log` +-- + +DROP TABLE IF EXISTS `user_achievements_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_achievements_log` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `achievement_id` varchar(36) NOT NULL, + `is_issued` tinyint(1) NOT NULL DEFAULT '0', + `vc_url` varchar(100) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_achievements_updated_by` (`updated_by`), + KEY `fk_user_achievements_created_by` (`created_by`), + KEY `fk_user_achievements_user_id` (`user_id`), + KEY `fk_user_achievements_achievement_id` (`achievement_id`), + CONSTRAINT `fk_user_achievements_achievement_id` FOREIGN KEY (`achievement_id`) REFERENCES `achievement` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_achievements_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_achievements_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_achievements_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_circle_link` +-- + +DROP TABLE IF EXISTS `user_circle_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_circle_link` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `circle_id` varchar(36) NOT NULL, + `lead` tinyint(1) DEFAULT NULL, + `is_invited` tinyint DEFAULT '0', + `accepted` tinyint(1) DEFAULT NULL, + `accepted_at` datetime DEFAULT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_circle_link_ref_circle_id` (`circle_id`), + KEY `fk_user_circle_link_ref_user_id` (`user_id`), + CONSTRAINT `fk_user_circle_link_ref_circle_id` FOREIGN KEY (`circle_id`) REFERENCES `learning_circle` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_circle_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_coupon_link` +-- + +DROP TABLE IF EXISTS `user_coupon_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_coupon_link` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(75) NOT NULL, + `coupon` varchar(15) NOT NULL, + `type` varchar(36) NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_coupon_link_ref_created_by` (`created_by`), + KEY `fk_user_coupon_link_ref_user_id` (`user_id`), + CONSTRAINT `fk_user_coupon_link_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_coupon_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_domains` +-- + +DROP TABLE IF EXISTS `user_domains`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_domains` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `domain_name` varchar(100) NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_domains_user_id` (`user_id`), + CONSTRAINT `fk_user_domains_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_endgoals` +-- + +DROP TABLE IF EXISTS `user_endgoals`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_endgoals` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `endgoal_name` varchar(100) NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_endgoals_user_id` (`user_id`), + CONSTRAINT `fk_user_endgoals_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_ig_link` +-- + +DROP TABLE IF EXISTS `user_ig_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_ig_link` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `ig_id` varchar(36) NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_ig_link_ref_user_id` (`user_id`), + KEY `fk_user_ig_link_ref_ig_id` (`ig_id`), + KEY `fk_user_ig_link_ref_created_by` (`created_by`), + CONSTRAINT `fk_user_ig_link_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_ig_link_ref_ig_id` FOREIGN KEY (`ig_id`) REFERENCES `interest_group` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_ig_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_lvl_link` +-- + +DROP TABLE IF EXISTS `user_lvl_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_lvl_link` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(75) NOT NULL, + `level_id` varchar(75) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`), + KEY `fk_user_lvl_link_ref_created_by` (`created_by`), + KEY `fk_user_lvl_link_ref_level_id` (`level_id`), + KEY `fk_user_lvl_link_ref_updated_by` (`updated_by`), + CONSTRAINT `fk_user_lvl_link_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_lvl_link_ref_level_id` FOREIGN KEY (`level_id`) REFERENCES `level` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_lvl_link_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_lvl_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_lvl_log` +-- + +DROP TABLE IF EXISTS `user_lvl_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_lvl_log` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(75) NOT NULL, + `level_id` varchar(75) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_lvl_log_ref_level_id` (`level_id`), + KEY `fk_user_lvl_log_ref_user_id` (`user_id`), + CONSTRAINT `fk_user_lvl_log_ref_level_id` FOREIGN KEY (`level_id`) REFERENCES `level` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_lvl_log_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_mentor` +-- + +DROP TABLE IF EXISTS `user_mentor`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_mentor` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `about` varchar(1000) DEFAULT NULL, + `reason` varchar(1000) DEFAULT NULL, + `hours` int NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime DEFAULT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_mentor_ref_user` (`user_id`), + KEY `fk_user_mentor_ref_updated_by` (`updated_by`), + KEY `fk_user_mentor_ref_created_by` (`created_by`), + CONSTRAINT `fk_user_mentor_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_mentor_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_mentor_ref_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_organization_link` +-- + +DROP TABLE IF EXISTS `user_organization_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_organization_link` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `org_id` varchar(36) NOT NULL, + `department_id` varchar(36) DEFAULT NULL, + `graduation_year` varchar(10) DEFAULT NULL, + `verified` tinyint(1) NOT NULL DEFAULT '0', + `is_alumni` tinyint(1) DEFAULT '0', + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_organization_link_ref_department_id` (`department_id`), + KEY `fk_user_organization_link_ref_user_id` (`user_id`), + KEY `fk_user_organization_link_ref_org_id` (`org_id`), + KEY `fk_user_organization_link_ref_created_by` (`created_by`), + CONSTRAINT `fk_user_organization_link_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_organization_link_ref_department_id` FOREIGN KEY (`department_id`) REFERENCES `department` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_organization_link_ref_org_id` FOREIGN KEY (`org_id`) REFERENCES `organization` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_organization_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_referral_link` +-- + +DROP TABLE IF EXISTS `user_referral_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_referral_link` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `referral_id` varchar(36) NOT NULL, + `is_coin` tinyint(1) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_referral_link_ref_user_id` (`user_id`), + KEY `fk_user_referral_link_referral_id` (`referral_id`), + KEY `fk_user_referral_link_ref_updated_by` (`updated_by`), + KEY `fk_user_referral_link_ref_created_by` (`created_by`), + CONSTRAINT `fk_user_referral_link_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_referral_link_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_referral_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_referral_link_referral_id` FOREIGN KEY (`referral_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_role_link` +-- + +DROP TABLE IF EXISTS `user_role_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_role_link` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `role_id` varchar(36) NOT NULL, + `verified` tinyint(1) NOT NULL DEFAULT '0', + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_user_role_link_ref_user_id` (`user_id`), + KEY `fk_user_role_link_ref_role_id` (`role_id`), + KEY `fk_user_role_link_ref_created_by` (`created_by`), + CONSTRAINT `fk_user_role_link_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_role_link_ref_role_id` FOREIGN KEY (`role_id`) REFERENCES `role` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_role_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `user_settings` +-- + +DROP TABLE IF EXISTS `user_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_settings` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT '0', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `is_userterms_approved` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `fk_user_settings_ref_user_id` (`user_id`), + KEY `fk_user_settings_created_by` (`created_by`), + KEY `fk_user_settings_updated_by` (`updated_by`), + CONSTRAINT `fk_user_settings_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_settings_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_settings_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `voucher_log` +-- + +DROP TABLE IF EXISTS `voucher_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `voucher_log` ( + `id` varchar(36) NOT NULL, + `code` varchar(15) NOT NULL, + `user_id` varchar(36) NOT NULL, + `task_id` varchar(36) NOT NULL, + `karma` int NOT NULL DEFAULT '0', + `mail` varchar(200) NOT NULL, + `week` varchar(2) DEFAULT NULL, + `month` varchar(10) NOT NULL, + `claimed` tinyint(1) NOT NULL, + `event` varchar(50) DEFAULT NULL, + `description` varchar(50) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_voucher_log_ref_created_by` (`created_by`), + KEY `fk_voucher_log_ref_task_id` (`task_id`), + KEY `fk_voucher_log_ref_user_id` (`user_id`), + CONSTRAINT `fk_voucher_log_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_voucher_log_ref_task_id` FOREIGN KEY (`task_id`) REFERENCES `task_list` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_voucher_log_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `wallet` +-- + +DROP TABLE IF EXISTS `wallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `wallet` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `karma` bigint NOT NULL DEFAULT '0', + `karma_last_updated_at` datetime DEFAULT NULL, + `coin` float NOT NULL DEFAULT '0', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`), + KEY `fk_total_karma_ref_updated_by` (`updated_by`), + KEY `fk_total_karma_ref_created_by` (`created_by`), + CONSTRAINT `fk_total_karma_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_total_karma_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_total_karma_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `zone` +-- + +DROP TABLE IF EXISTS `zone`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `zone` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `state_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_zone_ref_state_id` (`state_id`), + KEY `fk_zone_ref_updated_by` (`updated_by`), + KEY `fk_zone_ref_created_by` (`created_by`), + CONSTRAINT `fk_zone_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_zone_ref_state_id` FOREIGN KEY (`state_id`) REFERENCES `state` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_zone_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `comic_comment` +-- + +DROP TABLE IF EXISTS `comic_comment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `comic_comment` ( + `id` varchar(36) NOT NULL, + `comic_id` varchar(36) NOT NULL, + `chapter_id` varchar(36) DEFAULT NULL, + `parent_id` varchar(36) DEFAULT NULL, + `user_id` varchar(36) NOT NULL, + `message` text NOT NULL, + `deleted_at` datetime DEFAULT NULL, + `deleted_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_comic_comment_comic` (`comic_id`,`created_at`), + KEY `idx_comic_comment_chapter` (`chapter_id`,`created_at`), + KEY `idx_comic_comment_parent` (`parent_id`), + KEY `idx_comic_comment_user` (`user_id`), + CONSTRAINT `fk_comic_comment_ref_comic_id` FOREIGN KEY (`comic_id`) REFERENCES `comic` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `comic_comment` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_del_by` FOREIGN KEY (`deleted_by`) REFERENCES `user` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_comic_comment_ref_upd_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_cre_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `chapter` +-- + +DROP TABLE IF EXISTS `chapter`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `chapter` ( + `id` varchar(36) NOT NULL, + `comic_id` varchar(36) NOT NULL, + `title` varchar(150) NOT NULL, + `slug` varchar(75) NOT NULL, + `description` text DEFAULT NULL, + `chapter_number` decimal(6,2) NOT NULL, + `cover_image_key` varchar(255) DEFAULT NULL, + `status` varchar(10) NOT NULL DEFAULT 'draft', + `published_at` datetime DEFAULT NULL, + `deleted_at` datetime DEFAULT NULL, + `deleted_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`), + UNIQUE KEY `uq_comic_chapter_number` (`comic_id`,`chapter_number`), + KEY `idx_chapter_status_created` (`status`,`created_at`), + KEY `idx_chapter_comic_status` (`comic_id`,`status`), + CONSTRAINT `fk_chapter_ref_comic_id` FOREIGN KEY (`comic_id`) REFERENCES `comic` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_chapter_ref_del_by` FOREIGN KEY (`deleted_by`) REFERENCES `user` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_chapter_ref_upd_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_chapter_ref_cre_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `chapter_page` +-- + +DROP TABLE IF EXISTS `chapter_page`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `chapter_page` ( + `id` varchar(36) NOT NULL, + `chapter_id` varchar(36) NOT NULL, + `page_number` int unsigned NOT NULL, + `image_key` varchar(255) NOT NULL, + `deleted_at` datetime DEFAULT NULL, + `deleted_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_chapter_page_number` (`chapter_id`,`page_number`), + KEY `idx_chapter_page_order` (`chapter_id`,`page_number`), + CONSTRAINT `fk_chapter_page_ref_chapter_id` FOREIGN KEY (`chapter_id`) REFERENCES `chapter` (`id`) ON DELETE CASCADE, +CREATE TABLE `user_settings` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT '0', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + `is_userterms_approved` tinyint(1) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `fk_user_settings_ref_user_id` (`user_id`), + KEY `fk_user_settings_created_by` (`created_by`), + KEY `fk_user_settings_updated_by` (`updated_by`), + CONSTRAINT `fk_user_settings_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_settings_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_user_settings_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `voucher_log` +-- + +DROP TABLE IF EXISTS `voucher_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `voucher_log` ( + `id` varchar(36) NOT NULL, + `code` varchar(15) NOT NULL, + `user_id` varchar(36) NOT NULL, + `task_id` varchar(36) NOT NULL, + `karma` int NOT NULL DEFAULT '0', + `mail` varchar(200) NOT NULL, + `week` varchar(2) DEFAULT NULL, + `month` varchar(10) NOT NULL, + `claimed` tinyint(1) NOT NULL, + `event` varchar(50) DEFAULT NULL, + `description` varchar(50) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_voucher_log_ref_created_by` (`created_by`), + KEY `fk_voucher_log_ref_task_id` (`task_id`), + KEY `fk_voucher_log_ref_user_id` (`user_id`), + CONSTRAINT `fk_voucher_log_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_voucher_log_ref_task_id` FOREIGN KEY (`task_id`) REFERENCES `task_list` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_voucher_log_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `wallet` +-- + +DROP TABLE IF EXISTS `wallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `wallet` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `karma` bigint NOT NULL DEFAULT '0', + `karma_last_updated_at` datetime DEFAULT NULL, + `coin` float NOT NULL DEFAULT '0', + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `user_id` (`user_id`), + KEY `fk_total_karma_ref_updated_by` (`updated_by`), + KEY `fk_total_karma_ref_created_by` (`created_by`), + CONSTRAINT `fk_total_karma_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_total_karma_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_total_karma_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `zone` +-- + +DROP TABLE IF EXISTS `zone`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `zone` ( + `id` varchar(36) NOT NULL, + `name` varchar(75) NOT NULL, + `state_id` varchar(36) NOT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `fk_zone_ref_state_id` (`state_id`), + KEY `fk_zone_ref_updated_by` (`updated_by`), + KEY `fk_zone_ref_created_by` (`created_by`), + CONSTRAINT `fk_zone_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_zone_ref_state_id` FOREIGN KEY (`state_id`) REFERENCES `state` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_zone_ref_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `comic_comment` +-- + +DROP TABLE IF EXISTS `comic_comment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `comic_comment` ( + `id` varchar(36) NOT NULL, + `comic_id` varchar(36) NOT NULL, + `chapter_id` varchar(36) DEFAULT NULL, + `parent_id` varchar(36) DEFAULT NULL, + `user_id` varchar(36) NOT NULL, + `message` text NOT NULL, + `deleted_at` datetime DEFAULT NULL, + `deleted_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_comic_comment_comic` (`comic_id`,`created_at`), + KEY `idx_comic_comment_chapter` (`chapter_id`,`created_at`), + KEY `idx_comic_comment_parent` (`parent_id`), + KEY `idx_comic_comment_user` (`user_id`), + CONSTRAINT `fk_comic_comment_ref_comic_id` FOREIGN KEY (`comic_id`) REFERENCES `comic` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `comic_comment` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_del_by` FOREIGN KEY (`deleted_by`) REFERENCES `user` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_comic_comment_ref_upd_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_comment_ref_cre_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `chapter` +-- + +DROP TABLE IF EXISTS `chapter`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `chapter` ( + `id` varchar(36) NOT NULL, + `comic_id` varchar(36) NOT NULL, + `title` varchar(150) NOT NULL, + `slug` varchar(75) NOT NULL, + `description` text DEFAULT NULL, + `chapter_number` decimal(6,2) NOT NULL, + `cover_image_key` varchar(255) DEFAULT NULL, + `status` varchar(10) NOT NULL DEFAULT 'draft', + `published_at` datetime DEFAULT NULL, + `deleted_at` datetime DEFAULT NULL, + `deleted_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `slug` (`slug`), + UNIQUE KEY `uq_comic_chapter_number` (`comic_id`,`chapter_number`), + KEY `idx_chapter_status_created` (`status`,`created_at`), + KEY `idx_chapter_comic_status` (`comic_id`,`status`), + CONSTRAINT `fk_chapter_ref_comic_id` FOREIGN KEY (`comic_id`) REFERENCES `comic` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_chapter_ref_del_by` FOREIGN KEY (`deleted_by`) REFERENCES `user` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_chapter_ref_upd_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_chapter_ref_cre_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `chapter_page` +-- + +DROP TABLE IF EXISTS `chapter_page`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `chapter_page` ( + `id` varchar(36) NOT NULL, + `chapter_id` varchar(36) NOT NULL, + `page_number` int unsigned NOT NULL, + `image_key` varchar(255) NOT NULL, + `deleted_at` datetime DEFAULT NULL, + `deleted_by` varchar(36) DEFAULT NULL, + `updated_by` varchar(36) NOT NULL, + `updated_at` datetime NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_chapter_page_number` (`chapter_id`,`page_number`), + KEY `idx_chapter_page_order` (`chapter_id`,`page_number`), + CONSTRAINT `fk_chapter_page_ref_chapter_id` FOREIGN KEY (`chapter_id`) REFERENCES `chapter` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_chapter_page_ref_del_by` FOREIGN KEY (`deleted_by`) REFERENCES `user` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_chapter_page_ref_upd_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_chapter_page_ref_cre_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +CREATE TABLE `comic_bookmark_link` ( + `id` varchar(36) NOT NULL, + `comic_id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_comic_bookmark` (`comic_id`,`user_id`), + KEY `fk_comic_bookmark_link_ref_created_by` (`created_by`), + KEY `idx_comic_bookmark_user` (`user_id`), + CONSTRAINT `fk_comic_bookmark_link_ref_comic_id` FOREIGN KEY (`comic_id`) REFERENCES `comic` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_bookmark_link_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`), + CONSTRAINT `fk_comic_bookmark_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `comic_like_link` ( + `id` varchar(36) NOT NULL, + `comic_id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `created_by` varchar(36) NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_comic_like` (`comic_id`,`user_id`), + KEY `fk_comic_like_link_ref_created_by` (`created_by`), + KEY `idx_comic_like_user` (`user_id`), + CONSTRAINT `fk_comic_like_link_ref_comic_id` FOREIGN KEY (`comic_id`) REFERENCES `comic` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_like_link_ref_created_by` FOREIGN KEY (`created_by`) REFERENCES `user` (`id`), + CONSTRAINT `fk_comic_like_link_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `comic_reading_progress` ( + `id` varchar(36) NOT NULL, + `user_id` varchar(36) NOT NULL, + `comic_id` varchar(36) NOT NULL, + `last_chapter_id` varchar(36) DEFAULT NULL, + `last_page_number` int DEFAULT NULL, + `updated_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_reading_progress` (`user_id`,`comic_id`), + KEY `fk_comic_reading_progress_ref_comic_id` (`comic_id`), + KEY `fk_comic_reading_progress_ref_last_chapter_id` (`last_chapter_id`), + CONSTRAINT `fk_comic_reading_progress_ref_comic_id` FOREIGN KEY (`comic_id`) REFERENCES `comic` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_comic_reading_progress_ref_last_chapter_id` FOREIGN KEY (`last_chapter_id`) REFERENCES `chapter` (`id`) ON DELETE SET NULL, + CONSTRAINT `fk_comic_reading_progress_ref_user_id` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +SET @@SESSION.SQL_LOG_BIN = @MYSQLDUMP_TEMP_LOG_BIN; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2026-01-03 23:24:46 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/scripts/add_responses.py b/scripts/add_responses.py new file mode 100644 index 000000000..8d7ba88d2 --- /dev/null +++ b/scripts/add_responses.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python +""" +Second-pass script: adds responses= to @extend_schema decorators that are missing it. + +Strategy per method (in priority order): +1. If method body has a serializer usage → use it (already handled by pass 1) +2. If any sibling method in the same class has a serializer → reuse it +3. If module has a serializer matching the class name → use it +4. Fallback → use CustomResponseSerializer from utils.schema_utils + +Run from mulearnbackend/ root: + python scripts/add_responses.py # all modules + python scripts/add_responses.py api/dashboard/mentor +""" +import ast +import re +import sys +from pathlib import Path + +BASE_DIR = Path(__file__).parent.parent +API_DIR = BASE_DIR / "api" + +HTTP_METHODS = {"get", "post", "put", "patch", "delete"} +FALLBACK = "CustomResponseSerializer" +FALLBACK_IMPORT = "from utils.schema_utils import CustomResponseSerializer" + + +def get_serializer_names_from_file(serializer_file: Path) -> list[str]: + """Return all serializer class names defined in a file.""" + try: + source = serializer_file.read_text(encoding="utf-8") + tree = ast.parse(source) + except Exception: + return [] + names = [] + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and "Serializer" in node.name: + names.append(node.name) + return names + + +def find_serializer_for_class(class_name: str, serializer_names: list[str]) -> str | None: + """Try to match a view class name to a serializer by naming convention.""" + base = re.sub(r"(API|APIView|View|CRUD)$", "", class_name).strip() + candidates = [ + f"{base}Serializer", + f"{base}ListSerializer", + f"{base}DetailSerializer", + f"{base}ResponseSerializer", + ] + for c in candidates: + if c in serializer_names: + return c + # Partial match: serializer name contains the base + for s in serializer_names: + if base.lower() in s.lower() or s.lower().replace("serializer", "") in base.lower(): + return s + return None + + +def find_class_serializers(view_source: str, class_name: str) -> str | None: + """Look at all methods in the class for any serializer usage.""" + try: + tree = ast.parse(view_source) + except Exception: + return None + lines = view_source.splitlines() + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef) or node.name != class_name: + continue + for method in node.body: + if not isinstance(method, ast.FunctionDef): + continue + body = "\n".join(lines[method.lineno:method.end_lineno]) + for match in re.finditer(r"((?:\w+\.)?(?:\w+Serializer))\s*\(", body): + name = match.group(1) + if name and not name.endswith(".Serializer") and "BaseSerializer" not in name: + return name + return None + + +def method_has_responses(decorator_text: str) -> bool: + return "responses=" in decorator_text or "response=" in decorator_text.lower() + + +def get_decorator_span(lines: list[str], decorator_start: int) -> tuple[int, int]: + """Find the end line of a decorator (handles multi-line).""" + depth = 0 + for i in range(decorator_start, len(lines)): + depth += lines[i].count("(") - lines[i].count(")") + if depth <= 0 and i > decorator_start: + return decorator_start, i + if depth == 0 and i == decorator_start: + return decorator_start, decorator_start + return decorator_start, decorator_start + + +def patch_file(file_path: Path, serializer_names: list[str]) -> int: + source = file_path.read_text(encoding="utf-8") + lines = source.splitlines() + + try: + tree = ast.parse(source) + except SyntaxError: + return 0 + + patches: list[tuple[int, int, str]] = [] # (start_line_0idx, end_line_0idx, new_text) + + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + bases_str = " ".join(ast.unparse(b) for b in node.bases) + if "View" not in bases_str: + continue + + # Find class-level serializer (any method in this class) + class_serializer = find_class_serializers(source, node.name) + # Try name-based match as fallback + name_match = find_serializer_for_class(node.name, serializer_names) + + for method in node.body: + if not isinstance(method, ast.FunctionDef): + continue + if method.name not in HTTP_METHODS: + continue + + # Find the @extend_schema decorator on this method + for dec in method.decorator_list: + dec_text = ast.unparse(dec) + if "extend_schema" not in dec_text: + continue + if method_has_responses(dec_text): + continue # already has responses= + + # Determine what responses= to add + serializer = class_serializer or name_match or FALLBACK + + # Find actual line span of this decorator + dec_line = dec.lineno - 1 # 0-indexed + dec_end = dec.end_lineno - 1 # 0-indexed + + # Get indentation + def_line = lines[method.lineno - 1] + indent = " " * (len(def_line) - len(def_line.lstrip())) + + # Rebuild decorator with responses= appended + original = "\n".join(lines[dec_line:dec_end + 1]) + stripped = original.rstrip() + if stripped.endswith(")"): + # Strip the closing ) and any whitespace/comma before it + core = stripped[:-1].rstrip() + sep = "," if not core.endswith(",") else "" + new_dec = core + f"{sep}\n{indent} responses={{200: {serializer}}},\n{indent})" + else: + new_dec = stripped + f", responses={{200: {serializer}}})" + + patches.append((dec_line, dec_end, new_dec)) + + if not patches: + return 0 + + # Ensure fallback import is present if we used it + used_fallback = any(FALLBACK in p[2] for p in patches) + has_fallback_import = FALLBACK_IMPORT in source or "schema_utils" in source + + # Apply patches in reverse order + for start, end, new_text in sorted(patches, key=lambda x: x[0], reverse=True): + lines[start:end + 1] = new_text.splitlines() + + if used_fallback and not has_fallback_import: + last_import_end = 0 + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)) and getattr(node, "col_offset", 0) == 0: + end = getattr(node, "end_lineno", node.lineno) + if end > last_import_end: + last_import_end = end + lines.insert(last_import_end, FALLBACK_IMPORT) + + result = "\n".join(lines) + if source.endswith("\n"): + result += "\n" + file_path.write_text(result, encoding="utf-8") + return len(patches) + + +def collect_view_files(root: Path) -> list[Path]: + files = list(root.rglob("*views*.py")) + list(root.rglob("*view.py")) + return sorted({f for f in files if "__pycache__" not in str(f)}) + + +def get_module_serializers(view_file: Path) -> list[str]: + """Find all serializer names available in the same module directory.""" + module_dir = view_file.parent + names = [] + for sf in module_dir.rglob("*serial*.py"): + names.extend(get_serializer_names_from_file(sf)) + return names + + +def main(): + target = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else API_DIR + view_files = collect_view_files(target) + + total = 0 + for vf in view_files: + serializer_names = get_module_serializers(vf) + count = patch_file(vf, serializer_names) + if count: + print(f" +{count:3d} {vf.relative_to(BASE_DIR)}") + total += count + + print(f"\nTotal: {total} responses= added across {len(view_files)} view files.") + + +if __name__ == "__main__": + main() diff --git a/scripts/annotate_schemas.py b/scripts/annotate_schemas.py new file mode 100644 index 000000000..d6388ef49 --- /dev/null +++ b/scripts/annotate_schemas.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +""" +Automatically adds @extend_schema decorators to all APIView HTTP methods. + +Run from mulearnbackend/ root: + python scripts/annotate_schemas.py # all modules + python scripts/annotate_schemas.py api/leaderboard # single module +""" +import ast +import re +import sys +from pathlib import Path + +BASE_DIR = Path(__file__).parent.parent +API_DIR = BASE_DIR / "api" + +HTTP_METHODS = {"get", "post", "put", "patch", "delete"} + +METHOD_ACTION_MAP = { + "get": "Retrieve", + "post": "Create", + "put": "Update", + "patch": "Partially update", + "delete": "Delete", +} + + +def get_tag(file_path: Path) -> list: + """api/dashboard/campus/campus_views.py -> ['Dashboard - Campus']""" + parts = file_path.parts + try: + api_idx = list(parts).index("api") + except ValueError: + return ["General"] + module_parts = list(parts[api_idx + 1 : -1]) + if not module_parts: + return ["General"] + return [" - ".join(p.replace("_", " ").title() for p in module_parts)] + + +def camel_to_words(name: str) -> str: + """CampusDetailsAPI -> 'Campus Details'""" + name = re.sub(r"(API|APIView|View|CRUD)$", "", name).strip() + name = re.sub(r"([A-Z])", r" \1", name).strip() + return name + + +def make_description(class_name: str, method: str) -> str: + action = METHOD_ACTION_MAP.get(method, method.capitalize()) + resource = camel_to_words(class_name) + return f"{action} {resource}." + + +def find_response_serializer(lines: list, start: int, end: int) -> str | None: + """Find serializer referenced in method body (response pattern, not data=).""" + body = "\n".join(lines[start:end]) + for match in re.finditer(r"((?:\w+\.)?(?:\w+Serializer))\s*\(", body): + name = match.group(1) + if not name or name.endswith(".Serializer") or "BaseSerializer" in name: + continue + after = body[match.end():][:20] + if "data=" in after: + continue + return name + return None + + +def find_request_serializer(lines: list, start: int, end: int) -> str | None: + """Find serializer instantiated with data=request.data (request body).""" + body = "\n".join(lines[start:end]) + matches = re.findall(r"((?:\w+\.)?(?:\w+Serializer))\s*\(\s*data\s*=", body) + return matches[0] if matches else None + + +def already_annotated(method_node: ast.FunctionDef) -> bool: + for d in method_node.decorator_list: + if "extend_schema" in ast.unparse(d): + return True + return False + + +def annotate_file(file_path: Path) -> int: + """Inject @extend_schema on all unannotated HTTP methods. Returns count added.""" + source = file_path.read_text(encoding="utf-8") + lines = source.splitlines() + + try: + tree = ast.parse(source) + except SyntaxError as e: + print(f" SKIP (SyntaxError): {file_path}: {e}") + return 0 + + tag = get_tag(file_path) + tag_str = str(tag) + + insertions: list[tuple[int, str]] = [] + + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + bases_str = " ".join(ast.unparse(b) for b in node.bases) + if "View" not in bases_str: + continue + + for method in node.body: + if not isinstance(method, ast.FunctionDef): + continue + if method.name not in HTTP_METHODS: + continue + if already_annotated(method): + continue + + description = make_description(node.name, method.name) + m_start = method.lineno # 1-indexed + m_end = method.end_lineno # 1-indexed + + response_s = find_response_serializer(lines, m_start, m_end) + request_s = ( + find_request_serializer(lines, m_start, m_end) + if method.name in {"post", "put", "patch"} + else None + ) + + def_line = lines[m_start - 1] + indent = " " * (len(def_line) - len(def_line.lstrip())) + + parts = [f"tags={tag_str}", f'description="{description}"'] + if request_s: + parts.append(f"request={request_s}") + if response_s: + parts.append(f"responses={{200: {response_s}}}") + + if len(parts) <= 2: + decorator = f"{indent}@extend_schema({', '.join(parts)})" + else: + inner = f",\n{indent} ".join(parts) + decorator = f"{indent}@extend_schema(\n{indent} {inner},\n{indent})" + + insertions.append((m_start - 1, decorator)) + + if not insertions: + return 0 + + if "extend_schema" not in source: + last_import_line = 0 + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)) and getattr(node, "col_offset", 0) == 0: + end = getattr(node, "end_lineno", node.lineno) + if end > last_import_line: + last_import_line = end + lines.insert(last_import_line, "from drf_spectacular.utils import extend_schema") + insertions = [(ln + 1, dec) for ln, dec in insertions] + + for line_no, decorator in sorted(insertions, key=lambda x: x[0], reverse=True): + lines.insert(line_no, decorator) + + result = "\n".join(lines) + if source.endswith("\n"): + result += "\n" + file_path.write_text(result, encoding="utf-8") + return len(insertions) + + +def collect_view_files(root: Path) -> list[Path]: + files = list(root.rglob("*views*.py")) + list(root.rglob("*view.py")) + return sorted({f for f in files if "__pycache__" not in str(f)}) + + +def main(): + target = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else API_DIR + view_files = collect_view_files(target) + + total_annotations = 0 + for vf in view_files: + count = annotate_file(vf) + if count: + print(f" +{count:3d} {vf.relative_to(BASE_DIR)}") + total_annotations += count + + print(f"\nTotal: {total_annotations} decorators added across {len(view_files)} view files.") + + +if __name__ == "__main__": + main() diff --git a/utils/karma.py b/utils/karma.py index d066f705d..a14afe829 100644 --- a/utils/karma.py +++ b/utils/karma.py @@ -70,3 +70,31 @@ def add_karma( wallet.updated_at = DateTimeUtils.get_current_utc_time() wallet.save() return True + + +def remove_karma( + user_id: str | list[str], hashtag: str, karma: int | None = None +): + """Reverse karma awarded for a given hashtag (e.g. on report deletion).""" + task = TaskList.objects.filter(hashtag=hashtag).first() + if not task: + return False + if not karma: + karma = task.karma + user_ids = user_id if isinstance(user_id, list) else [user_id] + if User.objects.filter(id__in=user_ids).count() != len(user_ids): + return False + for uid in user_ids: + latest = ( + KarmaActivityLog.objects.filter(user_id=uid, task=task) + .order_by("-created_at") + .first() + ) + if latest: + latest.delete() + Wallet.objects.filter(user_id__in=user_ids).update( + karma=F("karma") - karma, + karma_last_updated_at=DateTimeUtils.get_current_utc_time(), + updated_at=DateTimeUtils.get_current_utc_time(), + ) + return True diff --git a/utils/mentor_permissions.py b/utils/mentor_permissions.py new file mode 100644 index 000000000..747a2e650 --- /dev/null +++ b/utils/mentor_permissions.py @@ -0,0 +1,143 @@ +""" +Mentor-specific DRF permission classes. + +These work in tandem with CustomizePermission (JWT auth). +After auth, views that require IG-scoped mentor access apply one of these classes. + +Persona context is read fresh from user_settings on every request — +the DB is the source of truth, not the JWT payload. +""" + +from rest_framework.permissions import BasePermission +from rest_framework.exceptions import PermissionDenied + +from db.user import UserRoleLink, UserMentor, UserSettings +from utils.permission import JWTUtils + + +def _get_persona_context(request): + """ + Fetch and cache the current user's active persona state from user_settings. + Attaches result to request._mentor_persona_context to avoid duplicate DB hits + within the same request cycle. + + Returns a dict with keys: active_persona, role_link_id, ig_id, is_verified + or None if the user has no active mentor persona. + """ + if hasattr(request, '_mentor_persona_context'): + return request._mentor_persona_context + + try: + user_id = JWTUtils.fetch_user_id(request) + except Exception: + request._mentor_persona_context = None + return None + + settings_qs = ( + UserSettings.objects + .select_related('active_role_link', 'active_ig') + .filter(user_id=user_id) + .first() + ) + + if not settings_qs or settings_qs.active_persona != 'mentor': + request._mentor_persona_context = None + return None + + role_link_id = settings_qs.active_role_link_id + ig_id = settings_qs.active_ig_id + + if not role_link_id or not ig_id: + request._mentor_persona_context = None + return None + + # Validate that the role_link is still active (handles mid-session revocation) + role_link = ( + UserRoleLink.objects + .filter(id=role_link_id, user_id=user_id, ig_id=ig_id, is_active=True) + .first() + ) + + if not role_link: + request._mentor_persona_context = None + return None + + context = { + 'user_id': user_id, + 'active_persona': 'mentor', + 'role_link_id': role_link_id, + 'ig_id': ig_id, + 'role_link': role_link, + } + request._mentor_persona_context = context + return context + + +class IsIGMentor(BasePermission): + """ + Grants access if the user's active persona is 'mentor' + and the backing user_role_link row is still active. + + Reads from user_settings (DB source of truth). + Uses request._mentor_persona_context cache — zero extra DB hit if + another permission class or middleware already populated it. + """ + message = "Active mentor persona required for this IG." + + def has_permission(self, request, view): + context = _get_persona_context(request) + if context is None: + raise PermissionDenied(self.message) + return True + + +class IsVerifiedIGMentor(BasePermission): + """ + Extends IsIGMentor: additionally requires is_verified=True and + mentor_tier='VERIFIED' on the user_mentor row. + + Used for: session creation, boot camp creation, verified-only actions. + Source of verification: user_mentor table (NOT the role table). + """ + message = "Verified mentor status required for this action." + + def has_permission(self, request, view): + context = _get_persona_context(request) + if not context: + raise PermissionDenied(self.message) + + has_verified_mentor = UserMentor.objects.filter( + user_id=context['user_id'], + is_verified=True, + mentor_tier=UserMentor.MentorTier.VERIFIED, + ).exists() + + if not has_verified_mentor: + raise PermissionDenied(self.message) + + return True + + +class HasIGAccess(BasePermission): + """ + Validates that the IG in the URL kwargs matches the user's active persona IG. + Prevents cross-IG access even with a valid mentor token. + + Expects URL kwarg named 'ig_id'. Pure in-memory comparison — zero DB hits. + """ + message = "You do not have mentor access for the requested Interest Group." + + def has_permission(self, request, view): + context = _get_persona_context(request) + if not context: + raise PermissionDenied(self.message) + + url_ig_id = view.kwargs.get('ig_id') + if not url_ig_id: + # No IG in URL means not IG-restricted; allow if persona is valid + return True + + if context['ig_id'] != url_ig_id: + raise PermissionDenied(self.message) + + return True diff --git a/utils/permission.py b/utils/permission.py index 1f72ad1f3..1bffd98d3 100644 --- a/utils/permission.py +++ b/utils/permission.py @@ -1,6 +1,7 @@ import datetime from datetime import datetime +# pyrefly: ignore [missing-import] import jwt from django.conf import settings from django.http import HttpRequest @@ -15,6 +16,7 @@ from .response import CustomResponse from db.user import DynamicRole, DynamicUser +from utils.types import RoleType # def get_current_utc_time(): @@ -38,6 +40,15 @@ class CustomizePermission(BasePermission): token_prefix = "Bearer" secret_key = SECRET_KEY + def has_permission(self, request, view): + try: + JWTUtils.is_jwt_authenticated(request) + return True + except UnauthorizedAccessException as e: + raise e + except Exception as e: + raise UnauthorizedAccessException(str(e)) + def authenticate(self, request): """ Authenticates the user based on the bearer token in the header. @@ -202,35 +213,44 @@ def wrapped_view_func(obj, request, *args, **kwargs): return decorator -# class RoleRequired: -# """ -# Class-based view that restricts access to views based on user roles. -# -# Usage: -# @method_decorator(RoleRequired(['admin'])) -# def my_view(request, arg1, arg2): -# ... -# """ -# -# def __init__(self, roles: List[str]): -# self.roles = roles -# -# def __call__(self, view_func): -# def wrapped_view_func(obj, request: HttpRequest, *args, **kwargs): -# # If a RoleType enum is provided, use its value instead -# for index, role in enumerate(self.roles): -# if isinstance(role, RoleType): -# self.roles[index] = role.value -# -# # Check if the user has one of the allowed roles -# for jwt_role in JWTUtils.fetch_role(request): -# if jwt_role in self.roles: -# response = view_func(obj, request, *args, **kwargs) -# return response -# -# # If the user does not have the required role, return a failure response -# return CustomResponse( -# general_message="You do not have the required role to access this page." -# ).get_failure_response() -# -# return wrapped_view_func +class RoleRequired: + """ + Class-based view that restricts access to views based on user roles. + + Usage: + @method_decorator(RoleRequired([RoleType.ADMIN.value])) + def my_view(request, arg1, arg2): + ... + """ + + def __init__(self, roles: list): + self.roles = roles + + def __call__(self, view_func): + def wrapped_view_func(obj, request: HttpRequest, *args, **kwargs): + # If a RoleType enum is provided, use its value instead + for index, role in enumerate(self.roles): + if isinstance(role, RoleType): + self.roles[index] = role.value + + # Check if the user has one of the allowed roles + for jwt_role in JWTUtils.fetch_role(request): + if jwt_role in self.roles: + response = view_func(obj, request, *args, **kwargs) + return response + + # If the user does not have the required role, return a failure response + return CustomResponse( + general_message="You do not have the required role to access this page." + ).get_failure_response() + + return wrapped_view_func + + +class BackendApiKeyPermission(BasePermission): + """ + Check for BACKEND_API_KEY in the headers. + """ + def has_permission(self, request, view): + api_key = request.headers.get("Api-Key") + return api_key == settings.BACKEND_API_KEY diff --git a/utils/schema_utils.py b/utils/schema_utils.py new file mode 100644 index 000000000..f19484c9c --- /dev/null +++ b/utils/schema_utils.py @@ -0,0 +1,19 @@ +"""Shared serializers for @extend_schema CustomResponse envelope documentation.""" +from rest_framework import serializers + + +class MessageSerializer(serializers.Serializer): + general = serializers.ListField(child=serializers.CharField(), allow_empty=True, required=False) + + +class CustomResponseSerializer(serializers.Serializer): + """Matches the standard CustomResponse envelope returned by all API views.""" + hasError = serializers.BooleanField(default=False) + statusCode = serializers.IntegerField( + default=200, + help_text="Application-level status code (200 on success, 400 on failure)." + ) + message = MessageSerializer() + response = serializers.JSONField( + help_text="Free-form response payload; shape varies per endpoint." + ) diff --git a/utils/spectacular.py b/utils/spectacular.py new file mode 100644 index 000000000..96d627715 --- /dev/null +++ b/utils/spectacular.py @@ -0,0 +1,59 @@ +def _envelope(inner_schema): + """Wrap a schema in the standard CustomResponse envelope.""" + return { + "type": "object", + "properties": { + "hasError": {"type": "boolean", "default": False, "example": False}, + "statusCode": {"type": "integer", "default": 200, "example": 200}, + "message": { + "type": "object", + "properties": { + "general": {"type": "array", "items": {"type": "string"}} + }, + }, + "response": inner_schema, + }, + "required": ["hasError", "statusCode", "message", "response"], + } + + +def custom_postprocessing_hook(result, generator, **kwargs): + """ + 1. Groups endpoints by functional area based on URL structure. + 2. Wraps all 200/201 JSON response schemas in the CustomResponse envelope + {hasError, statusCode, message, response: }. + """ + paths = result.get("paths", {}) + + for path, path_obj in paths.items(): + segments = [s for s in path.split("/") if s] + tag = "General" + if len(segments) >= 3 and segments[0] == "api" and segments[1] == "v1": + if segments[2] == "dashboard" and len(segments) >= 4: + tag = segments[3].replace("-", " ").title() + else: + tag = segments[2].replace("-", " ").title() + + for method, operation in path_obj.items(): + if method.lower() not in ["get", "post", "put", "patch", "delete", "options", "head"]: + continue + + operation["tags"] = [tag] + + for status_code, response_obj in operation.get("responses", {}).items(): + if str(status_code) not in ("200", "201"): + continue + for media_obj in response_obj.get("content", {}).values(): + inner = media_obj.get("schema") + if not inner: + continue + # Skip schemas that are already the CustomResponse envelope + ref = inner.get("$ref", "") + if ref.endswith("/CustomResponse") or ref.endswith("/CustomResponseSerializer"): + continue + # Skip if already has hasError at top level (already enveloped) + if "hasError" in inner.get("properties", {}): + continue + media_obj["schema"] = _envelope(inner) + + return result diff --git a/utils/types.py b/utils/types.py index a589a953f..48b850b26 100644 --- a/utils/types.py +++ b/utils/types.py @@ -34,6 +34,7 @@ class RoleType(Enum): DISTRICT_CAMPUS_LEAD = "District Campus Lead" MENTOR = "Mentor" INTERN = "Intern" + INTERN_LEAD = "Intern Lead" CAMPUS_LEAD = "Campus Lead" BOT_DEV = "Bot Dev" PRE_MEMBER = "Pre Member" @@ -44,6 +45,9 @@ class RoleType(Enum): IG_LEAD = "IG Lead" CAMPUS_ACTIVATION_TEAM = "Campus Activation Team" LEAD_ENABLER = "Lead Enabler" + MULEARNER = "Mulearner" + COMPANY = "Company" + COMIC_ADMIN = "Comic Admin" @classmethod def IG_CAMPUS_LEAD_ROLE(cls, ig_code: str): @@ -192,6 +196,16 @@ def get_all_values(cls): return [member.value for member in cls] +class UnitType(Enum): + LEVEL = "level" + KARMA = "karma" + TASK = "task" + + @classmethod + def get_all_values(cls): + return [member.value for member in cls] + + DEFAULT_HACKATHON_FORM_FIELDS = { "name": "system", "gender": "system", @@ -203,3 +217,106 @@ def get_all_values(cls): "linkedin": "input", "bio": "input", } + + +class SocialPlatformType(Enum): + INSTAGRAM = "instagram" + LINKEDIN = "linkedin" + TWITTER = "twitter" + FACEBOOK = "facebook" + YOUTUBE = "youtube" + DISCORD = "discord" + GITHUB = "github" + WEBSITE = "website" + OTHER = "other" + + @classmethod + def get_all_values(cls): + return [member.value for member in cls] + +class InternHashtag(Enum): + DAILY_LOG_KARMA = 0 + DAILY_LOG_HASHTAG = "#intern-daily-log" + WEEKLY_REVIEW_KARMA = 0 + WEEKLY_REVIEW_HASHTAG = "#intern-weekly-review" + TASK_VERIFIED_HASHTAG = "#intern-task-verified" + STREAK_7_KARMA = 7 + STREAK_7_HASHTAG = "#intern-streak-7" + STREAK_14_KARMA = 14 + STREAK_14_HASHTAG = "#intern-streak-14" + STREAK_30_KARMA = 30 + STREAK_30_HASHTAG = "#intern-streak-30" + STREAK_60_KARMA = 60 + STREAK_60_HASHTAG = "#intern-streak-60" + STREAK_90_KARMA = 90 + STREAK_90_HASHTAG = "#intern-streak-90" + +class InternGuild(Enum): + FRONTEND = "Frontend Guild" + BACKEND = "Backend Guild" + DESIGN = "Design Guild" + MOBILE = "Mobile Guild" + + @classmethod + def get_all_values(cls): + return [member.value for member in cls] + +class InternTaskCategory(Enum): + BACKEND_GUILD = ["Backend API", "Auth API", "Bot", "Database", "DevOps", "Documentation"] + FRONTEND_GUILD = ["UI Components", "API Integration", "Bug Fix", "Performance", "Accessibility", "Documentation"] + DESIGN_GUILD = ["Wireframes", "Prototyping", "Branding", "Research"] + +class InternTaskStatus(Enum): + NOT_STARTED = "NOT_STARTED" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + WAITING_FOR_REVIEW = "WAITING_FOR_REVIEW" + ON_HOLD = "ON_HOLD" + OVERDUE = "OVERDUE" + + @classmethod + def intern_editable(cls): + """Statuses an intern can set manually.""" + return [cls.IN_PROGRESS.value, cls.COMPLETED.value, cls.ON_HOLD.value, cls.WAITING_FOR_REVIEW.value] + + @classmethod + def get_all_values(cls): + return [member.value for member in cls] + +class InternTaskComplexity(Enum): + LOW = ("LOW", 1) + MEDIUM = ("MEDIUM", 2) + HIGH = ("HIGH", 3) + CRITICAL = ("CRITICAL", 5) + +class InternSubmissionStatus(Enum): + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + +class InternLeaveType(Enum): + SICK = "SICK" + CASUAL = "CASUAL" + EMERGENCY = "EMERGENCY" + WFH = "WFH" + +class InternLeaveStatus(Enum): + PENDING = "PENDING" + APPROVED = "APPROVED" + REJECTED = "REJECTED" + CANCELLED = "CANCELLED" + +class InternGuildStatus(Enum): + ACTIVE = "ACTIVE" + AT_RISK = "AT_RISK" + ON_LEAVE = "ON_LEAVE" + INACTIVE = "INACTIVE" + +class InternLeaderboardWeights: + """Point-value multipliers for leaderboard scoring. + Tunable without code changes - only this class needs updating.""" + KARMA_MULTIPLIER = 1 # karma value used directly + DAILY_STREAK_MULTIPLIER = 50 # each streak day = 50 points + WEEKLY_STREAK_MULTIPLIER = 200 # each streak week = 200 points + COMPLETED_TASKS_MULTIPLIER = 30 # each completed task = 30 points + COMPLEXITY_SCORE_MULTIPLIER = 20 # each complexity point = 20 points diff --git a/utils/utils.py b/utils/utils.py index 6e871f190..22c71226e 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -1,5 +1,6 @@ import csv import datetime +import re import gzip import io from datetime import timedelta @@ -16,6 +17,17 @@ from django.http import HttpResponse from django.template.loader import render_to_string import string, random +from django.utils.timezone import now + +def check_alumni_status(graduation_year): + """ + Returns True if graduation_year is a valid 4-digit year and is in the past. + Mirrors the logic from mu_celery/alumni_cron.py so is_alumni is always + accurate at the point of creation or update — no cron lag. + """ + if graduation_year and re.match(r'^[0-9]{4}$', str(graduation_year)): + return int(graduation_year) < now().year + return False class CommonUtils: @@ -43,8 +55,15 @@ def get_paginated_queryset( if sort_fields is None: sort_fields = {} - page = int(request.query_params.get("pageIndex", 1)) - per_page = int(request.query_params.get("perPage", 10)) + try: + page = int(request.query_params.get("pageIndex", 1)) + except ValueError: + page = 1 + + try: + per_page = int(request.query_params.get("perPage", 10)) + except ValueError: + per_page = 10 search_query = request.query_params.get("search") sort_by = request.query_params.get("sortBy") @@ -90,10 +109,21 @@ def get_paginated_queryset( def generate_csv(queryset: QuerySet, csv_name: str) -> HttpResponse: response = HttpResponse(content_type="text/csv") response["Content-Disposition"] = f'attachment; filename="{csv_name}.csv"' - fieldnames = list(queryset[0].keys()) - writer = csv.DictWriter(response, fieldnames=fieldnames) - writer.writeheader() - writer.writerows(queryset) + + if not queryset: + fieldnames = [] + else: + # Collect all unique keys across all rows + fieldnames = [] + for row in queryset: + for key in row.keys(): + if key not in fieldnames: + fieldnames.append(key) + + writer = csv.DictWriter(response, fieldnames=fieldnames, extrasaction='ignore') + if fieldnames: + writer.writeheader() + writer.writerows(queryset) compressed_response = HttpResponse( gzip.compress(response.content), @@ -172,9 +202,10 @@ def general_updates(category, action, *values) -> str: content = f"{category}<|=|>{action}" for value in values: content = f"{content}<|=|>{value}" - url = config("DISCORD_WEBHOOK_LINK") - data = {"content": content} - requests.post(url, json=data) + url = config("DISCORD_WEBHOOK_LINK", default="") + if url: + data = {"content": content} + requests.post(url, json=data) class ImportCSV: @@ -234,7 +265,15 @@ def send_template_mail( to=[context["email"]], ) - email.attach(attachment) + # Handle different attachment formats + if isinstance(attachment, tuple) and len(attachment) == 3: + # Tuple format: (filename, content, mimetype) for binary attachments + filename, content, mimetype = attachment + email.attach(filename, content, mimetype) + else: + # Legacy format: file path or string + email.attach(attachment) + email.content_subtype = "html" return email.send()