Users - Models
Location: api/nextango/apps/users/models.py
Last Updated: 2026-07-08
Overview
The Users domain stores every person the platform knows about, plus the role-specific data attached to them. User is the single Sanity-synced identity record. Employee, Customer, Manager, Influencer, and Vip are role records that mirror Sanity's roleEmployee, roleCustomer, roleManager, roleInfluencer, and roleVip object types, but they are derived data: the authoritative role payload lives on User.user_roles_data, and the role rows are rebuilt from it. Role rows are created by a post-save signal on every save except sync-sourced saves, which skip the signal and instead rely on an explicit sync_roles_to_model_records() call (see Signals). UserProductCollection and SavedAddress round out the domain (wishlists/curations and checkout addresses), and LoyaltyLedger records the audit trail behind a customer's loyalty points balance.
All Sanity-synced models in this domain use camelCase field names on the Sanity side and snake_case on the Django side; field names below are the Django (snake_case) names unless noted.
User
Base class: SanityIntegratedModel
The platform identity record. Mirrors Sanity's user.js schema and doubles as the Django auth user (USERNAME_FIELD = 'email').
| Field | Type | Description |
|---|---|---|
name | CharField | Full name |
first_name, last_name | CharField, nullable | Optional given/family name split |
email | EmailField, unique | Login identifier |
phone | CharField, nullable | |
status | CharField (active, inactive, suspended) | Gates authentication (see Authentication) |
created_date | DateTimeField | Set on creation |
user_roles_data | JSONField | The Sanity userRoles array, stored as-is. Source of truth for role assignment |
collections | JSONField | References to owned UserProductCollection documents |
credentials | JSONField | Licence/permit credential objects synced from Sanity |
password | CharField, nullable | Hashed via set_password() (Django make_password) |
has_password, password_set_at | BooleanField, DateTimeField | Password lifecycle tracking, excluded from Sanity sync |
is_staff, is_superuser | BooleanField | Django admin access, driven by a manager role's adminAccess sub-object rather than set directly |
A functional index on Lower('email') backs the identity-reconcile lookup that matches an inbound Sanity user document to an existing Django row by normalized email (models.py:441-446).
Role helpers: get_primary_role() and get_all_roles() read user_roles_data and map Sanity role type strings (roleEmployee, roleCustomer, roleManager, roleInfluencer, roleVip) to their Django equivalents (employee, customer, manager, influencer, vip). has_role(role_type) checks whether the corresponding role row exists. sync_roles_to_model_records() rebuilds Employee/Customer/Manager/Influencer/Vip rows from user_roles_data, creating or updating one row per role present in the array and deleting rows for roles no longer present.
Cross-links: UserSerializer, UserService, role-sync signal.
Employee
Base class: SanityIntegratedModel, AuditableMixin
Derived from a roleEmployee entry in the parent user's user_roles_data. Not independently synced outbound (_outbound_sync_enabled = False); it syncs via the parent User document.
| Field | Type | Description |
|---|---|---|
user | ForeignKey → User (related_name='employee_roles') | Parent user |
employee_id | CharField | Identifier used for PIN login |
pin_code | CharField, nullable | 4 or 6-digit PIN for POS access |
hourly_rate | DecimalField, nullable | |
commission_rate | DecimalField, default 0 | Percentage |
store_assignments | JSONField | Array of Sanity store references |
hire_date | DateField, nullable |
Sanity's schema permits only one Employee role per user; the sync path matches on user alone rather than user + employee_id.
Cross-links: EmployeeSerializer / EmployeeRoleSerializer, UserAuthenticationService.authenticate_pin_login, PINLoginSerializer.
Customer
Base class: SanityIntegratedModel, AuditableMixin
Derived from a roleCustomer entry.
| Field | Type | Description |
|---|---|---|
user | ForeignKey → User (related_name='customer_roles') | Parent user |
loyalty_points | IntegerField, default 0 | Current balance (see LoyaltyLedger) |
store_credit | DecimalField, default 0 | Aggregated from active StoreCredit records via recalculate_store_credit() |
wishlist | JSONField | References to UserProductCollection documents |
marketing_opt_in | BooleanField, default False | |
preferred_store | CharField, nullable | Sanity store reference |
order_history | JSONField | References to sale transactions |
recalculate_store_credit() sums remainingBalance across the customer's active StoreCredit rows and persists the total to store_credit; it runs automatically when a linked StoreCredit record is saved (see Signals).
Cross-links: CustomerSerializer / CustomerRoleSerializer, CustomerAnalyticsService.
Manager
Base class: SanityIntegratedModel, AuditableMixin
Derived from a roleManager entry. Carries the adminAccess privilege data that gates User.is_staff / User.is_superuser.
| Field | Type | Description |
|---|---|---|
user | ForeignKey → User (related_name='manager_roles') | Parent user |
employee_id | CharField | Identifier used for PIN login |
pin_code | CharField, nullable | |
hourly_rate | DecimalField, nullable | |
department | CharField, nullable | |
reporting_level | CharField (Store Manager, Regional Manager, Corporate) | |
override_permissions | JSONField | Array of permission strings (canOverridePrice, canIssueRefund, canOverrideDiscount) |
reports_to | CharField, nullable | Sanity reference to a supervising user |
store_assignments | JSONField | Array of Sanity store references. An empty array on a manager row grants unrestricted store access rather than no access (see Permissions) |
Cross-links: ManagerSerializer / ManagerRoleSerializer, Permissions store-scope gate.
Influencer
Base class: SanityIntegratedModel, AuditableMixin
Derived from a roleInfluencer entry.
| Field | Type | Description |
|---|---|---|
user | ForeignKey → User (related_name='influencer_roles') | Parent user |
social_handle | CharField | |
commission_rate | DecimalField, nullable | |
follower_count | IntegerField, nullable | |
discount_code | CharField, nullable | Sanity reference to an associated promo code |
Vip
Base class: SanityIntegratedModel, AuditableMixin
Derived from a roleVip entry.
| Field | Type | Description |
|---|---|---|
user | ForeignKey → User (related_name='vip_roles') | Parent user |
vip_tier | CharField (Gold, Platinum, Diamond), nullable | |
personal_shopper_id | CharField, nullable | Sanity reference to a personal shopper user |
exclusive_access | JSONField | Array of access strings (earlyProductAccess, privateSales, vipEvents, conciergeService) |
special_discount_rate | DecimalField, nullable |
UserProductCollection
Base class: SanityIntegratedModel, AuditableMixin
A user-owned collection of inventory items: personal wishlist, influencer curation, or staff pick. Backs storefront discovery pages and personal shopping lists.
| Field | Type | Description |
|---|---|---|
title, slug (unique), description | CharField / SlugField / TextField | |
owner | ForeignKey → User (related_name='product_collections') | |
collection_type | CharField (wishlist, curation, staff-pick) | |
is_public, is_featured | BooleanField | Featured collections must be curation or staff-pick |
share_token | CharField, unique, nullable | Sharing URL token for private collections |
items | ManyToManyField → products.InventoryLevel (related_name='user_collections') | Store-specific product variants in the collection |
statistics | JSONField | Item count, total/average price, on-sale count, recomputed by update_statistics() whenever items change |
notification_settings, curation_metadata, staff_pick_metadata | JSONField | Type-specific extension data |
Cross-links: UserProductCollectionSerializer, UserProductCollectionViewSet.
SavedAddress
Base class: SanityIntegratedModel, AuditableMixin
A reusable checkout address. Embedded in the Sanity user record's savedAddresses array rather than synced as a standalone document (_outbound_sync_enabled = False).
| Field | Type | Description |
|---|---|---|
user | ForeignKey → User (related_name='saved_addresses') | |
label | CharField | e.g. "Home", "Work" |
is_default | BooleanField | Setting one address as default clears the flag on the user's other addresses |
first_name, last_name, company, address, apartment, city, region, postal_code, country, phone | CharField | Shipping address fields |
created_at, updated_at | DateTimeField | Set on creation / on every save |
LoyaltyLedger
Base class: TimestampedModel
An immutable, append-only record of every loyalty point event for a Customer. Customer.loyalty_points is the authoritative running balance; ledger entries let the balance be reconstructed and audited.
| Field | Type | Description |
|---|---|---|
customer | ForeignKey → Customer (related_name='loyalty_ledger') | |
event_type | CharField (earn, redeem, reverse, adjustment), not editable | |
points | IntegerField, not editable | Signed delta; a check constraint forbids a zero-point entry |
balance_after | IntegerField, not editable | Customer.loyalty_points immediately after this event |
sale_transaction | ForeignKey → transactions.SaleTransaction, nullable | The sale that triggered the event, if any |
notes | TextField, not editable |
A unique constraint prevents more than one earn entry per (customer, sale_transaction) pair, so a transaction cannot double-credit loyalty points on retry.