DATABASE SCHEMA
Models
User
Represents an authenticated user on the platform.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| String | Unique, Required | User email address | |
| full_name | String | Required | User's full name |
| avatar | String | Nullable | URL to avatar image (from Google) |
| is_active | Boolean | Default: True | Account active status |
| is_admin | Boolean | Default: False | Platform administrator flag (checked by API views) |
| is_staff | Boolean | Default: False | Django admin-site login flag (separate from is_admin) |
| created_at | DateTime | Auto now | Account creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
School
Represents an educational institution.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| name | String | Unique, Required | Full institution name |
| short_name | String | Unique, Required | Abbreviated name |
| slug | String | Unique, Auto-generated | URL-friendly identifier, generated once from short_name at creation, never regenerated on edit |
| location | String | Nullable | City/State location |
| website | String | Nullable | Official website URL |
| verification_status | Enum | Default: UNVERIFIED | UNVERIFIED/PENDING/VERIFIED |
| is_active | Boolean | Default: True | School active status (also used as soft-delete) |
| institution_type | Enum | Nullable | UNIVERSITY/POLYTECHNIC/COLLEGE_OF_EDUCATION. Deliberately kept to three values, see School Data Curation note below |
| ownership | Enum | Nullable | FEDERAL/STATE/PRIVATE |
| state | String | Nullable | Nigerian state, free text |
| regulatory_code | String | Nullable, Unique | Reserved for a JAMB institution code, not populated by the initial NUC/NBTE/NCCE import |
| source_url | String | Nullable | The regulator page this record's data was last confirmed against |
| last_verified_at | DateTime | Nullable | Set explicitly by the import process or an admin correction, not auto-derived |
| country | String | Default: "Nigeria" | Forward-compatible field for eventual multi-country expansion, no multi-country logic exists yet |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
has_hub is not a stored field, it's computed from the related Hub record.
School Data Curation note on institution_type: source regulator data contains
six raw categories (universities, polytechnics, colleges of education, allied
institutions, colleges of health technology, colleges of nursing). Allied
institutions, colleges of health technology, and colleges of nursing all collapse
into POLYTECHNIC as their nearest general category, since all three are
NBTE-regulated, sub-degree/technical institutions structurally closer to a
polytechnic than to a university or a college of education. The original,
more specific category string is preserved per-record on SchoolSourceRecord.raw_category
below, nothing from the source is actually lost, it's just not promoted to a
first-class School field.
SchoolSourceRecord
Per-regulator provenance for a School, one row per source confirmation. Exists
specifically to support "never auto-deactivate a School based on one source
disappearing" (see project-plan.md's Schools Are Platform Data principle): a
future re-pull of a regulator's list flips is_current to False here rather
than touching School.is_active directly, leaving deactivation a deliberate,
reviewed admin decision instead of an automatic side effect. No independent
API endpoint, admin-visible only (inline under School in Django admin).
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| school | ForeignKey(School) | Required, CASCADE | The school this record confirms |
| regulator | Enum | Required | NUC/NBTE/NCCE |
| raw_category | String | Required | The exact original source label (e.g. "Allied Institution", "College of Nursing"), preserved even though it's collapsed into a simpler School.institution_type |
| raw_payload | JSON | Default: empty object | VC/provost name, year established, anything else the source carried that isn't a dedicated School column |
| source_url | String | Required | The regulator page this record was sourced from |
| fetched_at | DateTime | Required | When this source data was pulled |
| is_current | Boolean | Default: True | Flipped False when a later re-pull of that regulator's list no longer finds this institution |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
Department
Represents an academic department within a school.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| school | ForeignKey(School) | Required, CASCADE | Parent school |
| name | String | Required | Department name |
| code | String | Nullable | Department code |
| is_active | Boolean | Default: True | Department active status |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
Hub
Represents the community space for a school. One-to-one with School.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| school | OneToOneField(School) | Required, CASCADE | Associated school |
| is_active | Boolean | Default: True | Hub active status |
| activated_at | DateTime | Nullable | Activation timestamp |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
HubActivationRequest
Represents a user request to activate a hub.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| school | ForeignKey(School) | Required, CASCADE | School being requested |
| user | ForeignKey(User) | Required, CASCADE | User who requested |
| status | Enum | Default: PENDING | PENDING/APPROVED/REJECTED |
| notes | Text | Nullable | Additional notes from user |
| reviewed_by | ForeignKey(User) | Nullable | Admin who reviewed |
| reviewed_at | DateTime | Nullable | Review timestamp |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
Question
Represents a question asked by a user.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| title | String | Required | Question title |
| slug | String | Auto-generated, not unique | Cosmetic, SEO-friendly URL segment generated from title. Regenerated on every edit. Never used for lookups, the UUID remains the canonical identifier. |
| body | Text | Required | Question content |
| author | ForeignKey(User) | Required, CASCADE | Question creator |
| hub | ForeignKey(Hub) | Required, CASCADE | School hub |
| department | ForeignKey(Department) | Nullable, SET_NULL | Optional department |
| status | Enum | Default: OPEN | OPEN/ANSWERED/SOLVED |
| view_count | Integer | Default: 0 | Number of views |
| is_locked | Boolean | Default: False | Locked/Closed status. Preventing new answers from being added |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
Answer
Represents an answer to a question.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| body | Text | Required | Answer content |
| author | ForeignKey(User) | Required, CASCADE | Answer creator |
| question | ForeignKey(Question) | Required, CASCADE | Parent question |
| is_best | Boolean | Default: False | Best answer flag |
| vote_score | Integer | Default: 0 | Total vote score |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
AnswerVote
Represents a vote on an answer.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| answer | ForeignKey(Answer) | Required, CASCADE | Voted answer |
| user | ForeignKey(User) | Required, CASCADE | Voter |
| vote_type | Enum | Required | UP/DOWN |
| created_at | DateTime | Auto now | Vote timestamp |
Unique constraint: (answer, user) to prevent duplicate votes.
Comment
Represents a comment on an answer.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| body | Text | Required | Comment content |
| author | ForeignKey(User) | Required, CASCADE | Comment creator |
| answer | ForeignKey(Answer) | Required, CASCADE | Parent answer |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
Tag
Represents a tag that can be applied to questions.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| name | String | Unique, Required | Tag name |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
QuestionTag
Many-to-many relationship between questions and tags.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| question | ForeignKey(Question) | Required, CASCADE | Associated question |
| tag | ForeignKey(Tag) | Required, CASCADE | Associated tag |
| created_at | DateTime | Auto now | Association timestamp |
Unique constraint: (question, tag) to prevent duplicates.
QuestionFollow
Represents a user following a question for update notifications, independent of authorship.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| user | ForeignKey(User) | Required, CASCADE | Following user |
| question | ForeignKey(Question) | Required, CASCADE | Followed question |
| created_at | DateTime | Auto now | Follow timestamp |
Unique constraint: (question, user) to prevent duplicate follows. No follower count is exposed anywhere in the API, only the requesting user's own follow state (is_following), consistent with Knowledge Over Social Activity.
Notification
Represents a notification for a user.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| user | ForeignKey(User) | Required, CASCADE | Recipient |
| type | Enum | Required | NEW_ANSWER/NEW_COMMENT/NEW_QUESTION/BEST_ANSWER/VOTE/MODERATOR_ASSIGNED/HUB_ACTIVATED/NEW_ACTIVATION_REQUEST/NEW_REPORT/USER_SUSPENDED/HUB_ACTIVATION_REJECTED/REPORT_REVIEWED |
| message | String | Required | Notification text |
| is_read | Boolean | Default: False | Read status |
| content_type | ForeignKey(ContentType) | Nullable | Related object's model type (Django ContentType) |
| object_id | UUID | Nullable | Related object's ID |
| created_at | DateTime | Auto now | Notification timestamp |
content_object is exposed via GenericForeignKey('content_type', 'object_id'). The public API still serializes this as related_object_type (string) / related_object_id (uuid), see api-contract.md.
Report
Represents a user report on content.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| reporter | ForeignKey(User) | Required, CASCADE | User who reported |
| type | Enum | Required | SPAM/ABUSE/MISINFORMATION/DUPLICATE |
| content_type | ForeignKey(ContentType) | Required | Reported object's model type (Django ContentType) |
| object_id | UUID | Required | ID of reported object |
| description | Text | Nullable | Additional context |
| status | Enum | Default: PENDING | PENDING/RESOLVED/REJECTED |
| resolved_by | ForeignKey(User) | Nullable, SET_NULL | Admin who resolved |
| resolved_at | DateTime | Nullable | Resolution timestamp |
| is_escalated | Boolean | Default: False | Set when a moderator or representative flags an existing report for priority admin review |
| escalated_by | ForeignKey(User) | Nullable, SET_NULL | Moderator/representative/admin who escalated |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
content_object is exposed via GenericForeignKey('content_type', 'object_id'). Using Django's ContentType framework (instead of a plain string field) means any future model, including SchoolReview, can be reported without a schema change. The public API still serializes this as content_type: "question" / content_id: uuid.
ModeratorAssignment
Represents a user assigned as moderator for a hub.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| user | ForeignKey(User) | Required, CASCADE | Assigned moderator |
| hub | ForeignKey(Hub) | Required, CASCADE | Moderated hub |
| is_active | Boolean | Default: True | Assignment active status |
| created_at | DateTime | Auto now | Assignment timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
Unique constraint: (user, hub) to prevent duplicate assignments.
SchoolRepresentativeAssignment
Represents a user assigned as school representative for a hub.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| user | ForeignKey(User) | Required, CASCADE | Assigned representative |
| hub | ForeignKey(Hub) | Required, CASCADE | Represented hub |
| is_active | Boolean | Default: True | Assignment active status |
| created_at | DateTime | Auto now | Assignment timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
Unique constraint: (user, hub) to prevent duplicate assignments.
StaticPage
Represents an admin-managed standalone content page (Privacy Policy, Terms of Service, staff onboarding guides, etc).
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| title | String | Required | Page title |
| slug | String | Unique, Auto-generated | URL-friendly identifier, generated once from title at creation, never regenerated on edit, same pattern as School |
| body | Text | Required | Markdown source, rendered client-side |
| visibility | Enum | Default: PUBLIC | PUBLIC/STAFF |
| is_published | Boolean | Default: False | Draft status, unpublished pages are only visible to admins |
| created_by | ForeignKey(User) | Nullable, SET_NULL | Author. Nullable so deleting a user account never takes a page down with it |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
"Staff" for visibility purposes means is_admin, or an active
ModeratorAssignment/SchoolRepresentativeAssignment for any hub (checked
globally, not scoped to a specific hub, since page visibility isn't
hub-specific). Deleting a StaticPage is a genuine hard delete, unlike
School/Department, since nothing else has a foreign key into it and
draft/publish already covers the "hide without losing" case.
APIClient (Future, Public API Phase)
Represents a registered external consumer of the public API.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| name | String | Required | Consumer/application name |
| owner | ForeignKey(User) | Nullable, SET_NULL | Admin who issued the key |
| key_prefix | String | Unique, Required | Public identifier shown in dashboards |
| hashed_secret | String | Required | Hashed API secret (never stored plain) |
| scopes | String | Required | Comma-separated permission scopes |
| rate_limit_tier | Enum | Default: STANDARD | STANDARD/ELEVATED/INTERNAL |
| is_active | Boolean | Default: True | Client active status |
| revoked_at | DateTime | Nullable | Revocation timestamp |
| created_at | DateTime | Auto now | Creation timestamp |
Relationships Diagram
School (1) ─────── (1) Hub
│ │
│ │
│ │
├── (N) Department │
│ │
│ ├── (N) Question
│ │ │
│ │ ├── (N) Answer
│ │ │ │
│ │ │ ├── (N) Comment
│ │ │ │
│ │ │ └── (N) AnswerVote
│ │ │
│ │ ├── (N) QuestionTag ── (N) Tag
│ │ │
│ │ └── (1) Author (User)
│ │
│ ├── (N) ModeratorAssignment
│ │
│ ├── (N) SchoolRepresentativeAssignment
│ │
│ └── (N) HubActivationRequest
│
└── (N) User ─────── (N) Notification
(N) Report
(N) HubActivationRequest
Report and Notification also relate generically (via Django ContentType) to any
reportable/notifiable model, currently Question, Answer, Comment; SchoolReview will
join this set once built, with no schema change required.
Constraints & Indexes
Performance Indexes
Question: (hub_id, created_at DESC) - For recent questionsQuestion: (hub_id, status) - For filtering by statusQuestion: (department_id) - For department filteringAnswer: (question_id, is_best) - For best answer lookupAnswerVote: (answer_id) - For vote aggregationNotification: (user_id, is_read, created_at DESC) - For user notificationsNotification: (content_type_id, object_id) - For generic relation lookupsReport: (status, created_at DESC) - For reports dashboardReport: (content_type_id, object_id) - For generic relation lookupsReport: (is_escalated, created_at DESC) - For escalated reports triage
Unique Constraints
School: name, short_name, slug, regulatory_codeDepartment: (school_id, name)Hub: school_idAnswerVote: (answer_id, user_id)QuestionTag: (question_id, tag_id)ModeratorAssignment: (user_id, hub_id)SchoolRepresentativeAssignment: (user_id, hub_id)APIClient: key_prefix
Cascade Behavior
- Deleting a
School→ Delete associatedHub,Department,HubActivationRequest,SchoolSourceRecord - Deleting a
Hub→ Delete associatedQuestion,ModeratorAssignment,SchoolRepresentativeAssignment - Deleting a
User→ Delete associatedQuestion,Answer,Comment,Notification,Report - Deleting a
Question→ Delete associatedAnswer,QuestionTag,QuestionFollow - Deleting an
Answer→ Delete associatedComment,AnswerVote - Deleting a
Tag→ Delete associatedQuestionTag(only ever happens via explicit admin action, see api-contract.md's Delete Tag / Merge Tag)
In practice, School is not hard-deleted through the API, see api-contract.md's
soft-delete note. Hard delete remains available at the database/admin level only.
Future Models (Not in MVP, reserved for planning)
SchoolReview
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| school | ForeignKey(School) | Required, CASCADE | Reviewed school |
| author | ForeignKey(User) | Required, CASCADE | Reviewer |
| title | String | Required | Review headline |
| body | Text | Required | Review content |
| overall_rating | Integer | Required, 1–5 | Overall star rating |
| status | Enum | Default: PUBLISHED | PUBLISHED/FLAGGED/REMOVED |
| created_at | DateTime | Auto now | Creation timestamp |
| updated_at | DateTime | Auto now | Last update timestamp |
ReviewCategoryScore
Per-category breakdown (Academics, Facilities, Social Life, Career Support, Value for Money).
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| review | ForeignKey(SchoolReview) | Required, CASCADE | Parent review |
| category | String | Required | Category name |
| score | Integer | Required, 1–5 | Category rating |
ReviewResponse
The school's single official public reply to a review.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| review | OneToOneField(SchoolReview) | Required, CASCADE | Review being responded to |
| author | ForeignKey(User) | Required, CASCADE | Must be an active school representative |
| body | Text | Required | Response content |
| created_at | DateTime | Auto now | Creation timestamp |
ReviewVote
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| review | ForeignKey(SchoolReview) | Required, CASCADE | Voted review |
| user | ForeignKey(User) | Required, CASCADE | Voter |
| vote_type | Enum | Required | HELPFUL/NOT_HELPFUL |
Unique constraint: (review, user).
Plan
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| name | String | Required | Plan name |
| price | Decimal | Required | Price per billing interval |
| billing_interval | Enum | Required | MONTHLY/YEARLY |
| features | JSON | Required | Feature flags unlocked by this plan |
Subscription
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| school | ForeignKey(School) | Required, CASCADE | Subscribing school |
| plan | ForeignKey(Plan) | Required | Active plan |
| status | Enum | Required | ACTIVE/CANCELED/PAST_DUE |
| external_provider_id | String | Nullable | Stripe/Paystack subscription ID |
| current_period_end | DateTime | Required | Renewal date |
Invoice
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| subscription | ForeignKey(Subscription) | Required, CASCADE | Related subscription |
| amount | Decimal | Required | Amount charged |
| status | Enum | Required | PAID/FAILED/PENDING |
| issued_at | DateTime | Auto now | Issue date |
| paid_at | DateTime | Nullable | Payment date |
Design constraint (see project-plan.md → Integrity Over Monetization): paid plans
may only affect visibility/promotion/verification. They must never gate deletion,
hiding, or suppression of a SchoolReview. Reviews are moderated exclusively through
the existing Report pipeline, regardless of the school's subscription status.
SchoolSubmission (Future, School Data Curation)
Represents a user-submitted school not yet in the platform's directory.
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | UUID | Primary Key | Unique identifier |
| submitted_by | ForeignKey(User) | Required, CASCADE | User who submitted it |
| name | String | Required | Proposed school name |
| location | String | Nullable | Proposed location |
| website | String | Nullable | Proposed website |
| notes | Text | Nullable | Any supporting context from the submitter |
| status | Enum | Default: PENDING | PENDING/APPROVED/REJECTED |
| reviewed_by | ForeignKey(User) | Nullable, SET_NULL | Admin who reviewed |
| reviewed_at | DateTime | Nullable | Review timestamp |
| resulting_school | ForeignKey(School) | Nullable, SET_NULL | Set once approved and created |
| created_at | DateTime | Auto now | Creation timestamp |