| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480 |
- from __future__ import annotations
- from django.conf import settings
- from django.contrib.auth import get_user_model
- from django.contrib.contenttypes.fields import GenericForeignKey
- from django.contrib.contenttypes.models import ContentType
- from django.db import models
- from django.utils import timezone
- from django.utils.translation import gettext_lazy as _
- from orgs.models import Organization
- from markdownfield.models import MarkdownField, RenderedMarkdownField
- from markdownfield.validators import VALIDATOR_STANDARD
- User = get_user_model()
- class TimestampedModel(models.Model):
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
- class Meta:
- abstract = True
- ## Organization and related tenancy models moved to orgs.models
- class MaterialCategory(TimestampedModel):
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_categories")
- # Limit to a curated, preset set of category names
- CATEGORY_CHOICES = (
- ("Plastics", "Plastics"),
- ("Metals", "Metals"),
- ("Paper", "Paper"),
- ("Glass", "Glass"),
- ("Electronics", "Electronics"),
- ("Wood", "Wood"),
- ("Rubber", "Rubber"),
- ("Textiles", "Textiles"),
- ("Organic", "Organic"),
- ("Mixed", "Mixed"),
- )
- name = models.CharField(max_length=255, choices=CATEGORY_CHOICES)
- class Meta:
- unique_together = ("organization", "name")
- def __str__(self):
- return self.name
- class ProvidedService(TimestampedModel):
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="services")
- TITLE_CHOICES = (
- ("Pickup & Logistics", _("Pickup & Logistics")),
- ("Material Sorting", _("Material Sorting")),
- ("Weighing & Ticketing", _("Weighing & Ticketing")),
- ("Invoicing & Payouts", _("Invoicing & Payouts")),
- ("Reporting & Analytics", _("Reporting & Analytics")),
- ("Marketplace & Bidding", _("Marketplace & Bidding")),
- ("Compliance & Audits", _("Compliance & Audits")),
- ("Consulting & Training", _("Consulting & Training")),
- )
- title = models.CharField(max_length=100, choices=TITLE_CHOICES)
- # Short summary text
- description = models.TextField()
- # Long-form markdown body
- body = MarkdownField(rendered_field="body_html", validator=VALIDATOR_STANDARD, blank=True, null=True)
- body_html = RenderedMarkdownField(blank=True, null=True)
- display_order = models.PositiveIntegerField(default=0)
- is_enabled = models.BooleanField(default=True)
- IMAGE_NAME_MAP = {
- "Pickup & Logistics": "pickup-logistics.png",
- "Material Sorting": "material-sorting.png",
- "Weighing & Ticketing": "weighing-ticketing.png",
- "Invoicing & Payouts": "invoicing-payouts.png",
- "Reporting & Analytics": "reporting-analytics.png",
- "Marketplace & Bidding": "marketplace-bidding.png",
- "Compliance & Audits": "compliance-audits.png",
- "Consulting & Training": "consulting-training.png",
- }
- @property
- def image_name(self) -> str:
- return self.IMAGE_NAME_MAP.get(self.title, "default-service.png")
- class Meta:
- ordering = ["display_order"]
- def __str__(self) -> str:
- return self.title
- class Material(TimestampedModel):
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="materials")
- # Preset category choices (no FK)
- CATEGORY_CHOICES = MaterialCategory.CATEGORY_CHOICES
- category = models.CharField(max_length=64, choices=CATEGORY_CHOICES)
- name = models.CharField(max_length=255)
- code = models.CharField(max_length=64, blank=True)
- # unit choices keep MVP simple; conversions out of scope for now
- UNIT_KG = "kg"
- UNIT_LB = "lb"
- UNIT_PCS = "pcs"
- UNIT_CHOICES = (
- (UNIT_KG, "Kilogram"),
- (UNIT_LB, "Pound"),
- (UNIT_PCS, "Pieces"),
- )
- default_unit = models.CharField(max_length=8, choices=UNIT_CHOICES, default=UNIT_KG)
- class Meta:
- unique_together = ("organization", "name")
- def __str__(self):
- return self.name
- class MaterialImage(TimestampedModel):
- material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name="images")
- image = models.ImageField(upload_to="materials/%Y/%m/")
- caption = models.CharField(max_length=255, blank=True)
- display_order = models.PositiveIntegerField(default=0)
- class Meta:
- ordering = ["display_order", "id"]
- def __str__(self) -> str:
- return self.caption or f"MaterialImage #{self.id}"
- class PriceList(TimestampedModel):
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="price_lists")
- name = models.CharField(max_length=255)
- currency_code = models.CharField(max_length=8, default="USD")
- effective_from = models.DateField(null=True, blank=True)
- effective_to = models.DateField(null=True, blank=True)
- def __str__(self):
- return f"{self.name} ({self.currency_code})"
- class PriceListItem(TimestampedModel):
- DIRECTION_BUY = "buy" # we pay the customer
- DIRECTION_SELL = "sell" # we invoice the customer
- DIRECTION_CHOICES = (
- (DIRECTION_BUY, "Buy from customer"),
- (DIRECTION_SELL, "Sell to customer"),
- )
- price_list = models.ForeignKey(PriceList, on_delete=models.CASCADE, related_name="items")
- material = models.ForeignKey(Material, on_delete=models.PROTECT, related_name="price_items")
- unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
- unit_price = models.DecimalField(max_digits=12, decimal_places=2)
- direction = models.CharField(max_length=8, choices=DIRECTION_CHOICES, default=DIRECTION_SELL)
- class Meta:
- unique_together = ("price_list", "material", "unit", "direction")
- class Customer(TimestampedModel):
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="customers")
- name = models.CharField(max_length=255)
- email = models.EmailField(blank=True)
- phone = models.CharField(max_length=64, blank=True)
- billing_address = models.TextField(blank=True)
- price_list = models.ForeignKey(PriceList, on_delete=models.SET_NULL, null=True, blank=True, related_name="customers")
- def __str__(self):
- return self.name
- class CustomerSite(TimestampedModel):
- customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="sites")
- name = models.CharField(max_length=255, blank=True)
- address = models.TextField()
- contact_name = models.CharField(max_length=255, blank=True)
- contact_phone = models.CharField(max_length=64, blank=True)
- contact_email = models.EmailField(blank=True)
- def __str__(self):
- return self.name or f"Site {self.id}"
- class ServiceAgreement(TimestampedModel):
- customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="agreements")
- site = models.ForeignKey(CustomerSite, on_delete=models.SET_NULL, null=True, blank=True, related_name="agreements")
- price_list = models.ForeignKey(PriceList, on_delete=models.PROTECT, related_name="agreements")
- valid_from = models.DateField(null=True, blank=True)
- valid_to = models.DateField(null=True, blank=True)
- class PickupOrder(TimestampedModel):
- STATUS_REQUESTED = "requested"
- STATUS_SCHEDULED = "scheduled"
- STATUS_EN_ROUTE = "en_route"
- STATUS_COLLECTING = "collecting"
- STATUS_AT_FACILITY = "at_facility"
- STATUS_WEIGHED = "weighed"
- STATUS_INVOICED = "invoiced"
- STATUS_COMPLETED = "completed"
- STATUS_CANCELED = "canceled"
- STATUS_CHOICES = (
- (STATUS_REQUESTED, "Requested"),
- (STATUS_SCHEDULED, "Scheduled"),
- (STATUS_EN_ROUTE, "En Route"),
- (STATUS_COLLECTING, "Collecting"),
- (STATUS_AT_FACILITY, "At Facility"),
- (STATUS_WEIGHED, "Weighed"),
- (STATUS_INVOICED, "Invoiced"),
- (STATUS_COMPLETED, "Completed"),
- (STATUS_CANCELED, "Canceled"),
- )
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="pickup_orders")
- customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="pickup_orders")
- site = models.ForeignKey(CustomerSite, on_delete=models.PROTECT, related_name="pickup_orders")
- status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_REQUESTED, db_index=True)
- scheduled_at = models.DateTimeField(null=True, blank=True)
- assigned_driver = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_pickups")
- created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_pickups")
- completed_at = models.DateTimeField(null=True, blank=True)
- notes = models.TextField(blank=True)
- def __str__(self):
- return f"Pickup #{self.id} - {self.customer.name}"
- class Meta:
- permissions = (
- ("assign_driver", "Can assign driver to pickup"),
- ("set_pickup_status", "Can set pickup status"),
- ("create_weigh_ticket", "Can create weigh ticket for pickup"),
- ("generate_invoice", "Can generate invoice for pickup"),
- )
- class PickupItem(TimestampedModel):
- pickup = models.ForeignKey(PickupOrder, on_delete=models.CASCADE, related_name="items")
- material = models.ForeignKey(Material, on_delete=models.PROTECT)
- estimated_qty = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
- unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
- class WeighTicket(TimestampedModel):
- pickup = models.ForeignKey(PickupOrder, on_delete=models.CASCADE, related_name="weigh_tickets")
- ticket_number = models.CharField(max_length=64, blank=True)
- gross_weight = models.DecimalField(max_digits=12, decimal_places=3)
- tare_weight = models.DecimalField(max_digits=12, decimal_places=3)
- net_weight = models.DecimalField(max_digits=12, decimal_places=3)
- unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
- recorded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="weigh_tickets")
- recorded_at = models.DateTimeField(default=timezone.now)
- # Finalization controls
- is_finalized = models.BooleanField(default=False)
- finalized_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="finalized_tickets")
- finalized_at = models.DateTimeField(null=True, blank=True)
- class Meta:
- permissions = (
- ("generate_invoice", "Can generate invoice from weigh ticket"),
- ("edit_weigh_ticket", "Can edit weigh ticket weights"),
- )
- class WeighLine(TimestampedModel):
- ticket = models.ForeignKey(WeighTicket, on_delete=models.CASCADE, related_name="lines")
- material = models.ForeignKey(Material, on_delete=models.PROTECT)
- quantity = models.DecimalField(max_digits=12, decimal_places=3)
- unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
- # Billing models have moved to the `billing` app
- class Document(TimestampedModel):
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="documents")
- file = models.FileField(upload_to="documents/%Y/%m/%d/")
- kind = models.CharField(max_length=64, blank=True)
- # Generic attachment to any model instance
- content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
- object_id = models.PositiveIntegerField()
- content_object = GenericForeignKey("content_type", "object_id")
- uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
- class AuditLog(TimestampedModel):
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="audit_logs")
- user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
- action = models.CharField(max_length=64)
- content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
- object_id = models.PositiveIntegerField()
- content_object = GenericForeignKey("content_type", "object_id")
- metadata = models.JSONField(default=dict, blank=True)
- class Meta:
- indexes = [
- models.Index(fields=["organization", "created_at"]),
- models.Index(fields=["action", "created_at"]),
- ]
- class ScrapListing(TimestampedModel):
- TYPE_OPEN = "open"
- TYPE_SEALED = "sealed"
- AUCTION_CHOICES = ((TYPE_OPEN, "Open"), (TYPE_SEALED, "Sealed"))
- STATUS_DRAFT = "draft"
- STATUS_OPEN = "open"
- STATUS_CLOSED = "closed"
- STATUS_AWARDED = "awarded"
- STATUS_CANCELED = "canceled"
- STATUS_CHOICES = (
- (STATUS_DRAFT, "Draft"),
- (STATUS_OPEN, "Open"),
- (STATUS_CLOSED, "Closed"),
- (STATUS_AWARDED, "Awarded"),
- (STATUS_CANCELED, "Canceled"),
- )
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="scrap_listings")
- customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="scrap_listings")
- site = models.ForeignKey(CustomerSite, on_delete=models.PROTECT, related_name="scrap_listings")
- title = models.CharField(max_length=255)
- description = models.TextField(blank=True)
- auction_type = models.CharField(max_length=16, choices=AUCTION_CHOICES, default=TYPE_OPEN)
- currency_code = models.CharField(max_length=8, default="USD")
- reserve_price = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
- min_increment = models.DecimalField(max_digits=12, decimal_places=2, 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=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True)
- is_public = models.BooleanField(default=True)
- created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_listings")
- def __str__(self):
- return f"Listing #{self.id} - {self.title}"
- class Meta:
- permissions = (
- ("open_listing", "Can open scrap listing"),
- ("close_listing", "Can close scrap listing"),
- ("award_listing", "Can award scrap listing"),
- )
- class ScrapListingItem(TimestampedModel):
- listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="items")
- material = models.ForeignKey(Material, on_delete=models.PROTECT)
- quantity_estimate = models.DecimalField(max_digits=12, decimal_places=3)
- unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
- class ScrapBid(TimestampedModel):
- STATUS_ACTIVE = "active"
- STATUS_RETRACTED = "retracted"
- STATUS_ACCEPTED = "accepted"
- STATUS_REJECTED = "rejected"
- STATUS_CHOICES = (
- (STATUS_ACTIVE, "Active"),
- (STATUS_RETRACTED, "Retracted"),
- (STATUS_ACCEPTED, "Accepted"),
- (STATUS_REJECTED, "Rejected"),
- )
- listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="bids")
- bidder_org = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="bids")
- bidder_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="bids")
- price_total = models.DecimalField(max_digits=14, decimal_places=2)
- message = models.TextField(blank=True)
- status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
- class Meta:
- indexes = [models.Index(fields=["listing", "price_total"])]
- class ScrapAward(TimestampedModel):
- listing = models.OneToOneField(ScrapListing, on_delete=models.CASCADE, related_name="award")
- winning_bid = models.ForeignKey(ScrapBid, on_delete=models.PROTECT, related_name="awards")
- pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="awards")
- notes = models.TextField(blank=True)
- class ScrapListingInvite(TimestampedModel):
- """Invite a specific organization (and optionally a user) to bid on a listing.
- When a listing is not public, only invited orgs may place bids.
- """
- listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="invites")
- invited_org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="listing_invites")
- invited_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="listing_invites")
- class Meta:
- unique_together = ("listing", "invited_org")
- # Carbon accounting models ---------------------------------------------------
- class MaterialEmissionFactor(TimestampedModel):
- """Emission factor for a given material per unit.
- Values are in kgCO2e per given unit (default kg). Factors can be
- organization-specific and time-bounded via effective dates.
- """
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_factors")
- material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name="emission_factors")
- unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
- kgco2e_per_unit = models.DecimalField(max_digits=16, decimal_places=6)
- source = models.CharField(max_length=255, blank=True)
- effective_from = models.DateField(null=True, blank=True)
- effective_to = models.DateField(null=True, blank=True)
- class Meta:
- indexes = [
- models.Index(fields=["organization", "material", "unit"]),
- ]
- unique_together = ()
- def __str__(self) -> str: # pragma: no cover - simple display
- return f"{self.material.name} {self.kgco2e_per_unit} kgCO2e/{self.unit}"
- class CarbonEvent(TimestampedModel):
- """Calculated carbon impact event (e.g., from a weigh ticket line).
- Events flow through an approvals queue before contributing to monthly
- CarbonBalance aggregates.
- """
- STATUS_PENDING = "pending"
- STATUS_APPROVED = "approved"
- STATUS_REJECTED = "rejected"
- STATUS_CHOICES = (
- (STATUS_PENDING, "Pending"),
- (STATUS_APPROVED, "Approved"),
- (STATUS_REJECTED, "Rejected"),
- )
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="carbon_events")
- weigh_ticket = models.ForeignKey(WeighTicket, on_delete=models.SET_NULL, null=True, blank=True, related_name="carbon_events")
- # Optional linkage to a specific line for traceability
- weigh_line = models.ForeignKey(WeighLine, on_delete=models.SET_NULL, null=True, blank=True, related_name="carbon_events")
- material = models.ForeignKey(Material, on_delete=models.SET_NULL, null=True, blank=True)
- quantity = models.DecimalField(max_digits=14, decimal_places=3)
- unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
- kgco2e = models.DecimalField(max_digits=18, decimal_places=6)
- event_date = models.DateTimeField(default=timezone.now, db_index=True)
- status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True)
- approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="approved_carbon_events")
- approved_at = models.DateTimeField(null=True, blank=True)
- notes = models.TextField(blank=True)
- class Meta:
- indexes = [
- models.Index(fields=["organization", "status"]),
- models.Index(fields=["organization", "event_date"]),
- ]
- unique_together = ("weigh_line",)
- class CarbonBalance(TimestampedModel):
- """Monthly aggregation of approved CarbonEvents per organization."""
- organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="carbon_balances")
- year = models.PositiveSmallIntegerField()
- month = models.PositiveSmallIntegerField()
- events_count = models.PositiveIntegerField(default=0)
- approved_kgco2e = models.DecimalField(max_digits=18, decimal_places=6, default=0)
- class Meta:
- unique_together = ("organization", "year", "month")
- indexes = [models.Index(fields=["organization", "year", "month"])]
- def __str__(self) -> str: # pragma: no cover - simple display
- return f"{self.organization.code} {self.year}-{self.month:02d}"
|