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}"