Skip to content

Models

nautobot_contract_models.models.contract.Contract

Bases: PrimaryModel

A vendor agreement, with start/end dates, recurring + one-time costs, and a status.

Attaches to one or more concrete Nautobot objects (Devices, Circuits, VirtualMachines, etc.) via :class:ContractAssignment — see that model's docstring for why we use a generic FK rather than a per-target M2M.

Source code in src/nautobot_contract_models/models/contract.py
@extras_features(
    "custom_fields",
    "custom_links",
    "custom_validators",
    "export_templates",
    "graphql",
    "relationships",
    "statuses",
    "webhooks",
)
class Contract(PrimaryModel):
    """A vendor agreement, with start/end dates, recurring + one-time costs, and a status.

    Attaches to one or more concrete Nautobot objects (Devices, Circuits,
    VirtualMachines, etc.) via :class:`ContractAssignment` — see that model's
    docstring for why we use a generic FK rather than a per-target M2M.
    """

    name = models.CharField(max_length=255)
    contract_number = models.CharField(
        max_length=100,
        blank=True,
        help_text="Vendor-supplied contract or master agreement number.",
    )
    provider = models.ForeignKey(
        to="nautobot_contract_models.ServiceProvider",
        on_delete=models.PROTECT,
        related_name="contracts",
        help_text="Counterparty on this agreement. PROTECT — can't delete a "
        "provider while contracts still reference it.",
    )
    tenant = models.ForeignKey(
        to="tenancy.Tenant",
        on_delete=models.SET_NULL,
        related_name="contract_models_contracts",
        null=True,
        blank=True,
        help_text="Owning tenant. Nullable — a contract may apply globally to "
        "the operator's whole estate rather than to one tenant.",
    )
    # related_name namespace-prefixed to avoid Django reverse-accessor collision
    # with nautobot-app-device-lifecycle's ContractLCM.status (both default to
    # Status.contracts otherwise, which trips fields.E304 at system-check time
    # and blocks Nautobot startup when both apps are installed).
    status = StatusField(blank=False, null=False, related_name="contract_models_contracts")
    start_date = models.DateField(help_text="When coverage starts.")
    end_date = models.DateField(help_text="When coverage ends (renewal target).")
    renewal_terms = models.CharField(
        max_length=255,
        blank=True,
        help_text="Free-form: e.g. 'Auto-renew unless cancelled 60d prior'.",
    )
    recurring_cost = models.DecimalField(
        max_digits=12,
        decimal_places=2,
        default=Decimal("0.00"),
        help_text="Periodic cost charged at the cadence set by ``billing_period`` (default monthly).",
    )
    billing_period = models.CharField(
        max_length=20,
        choices=BillingPeriodChoices,
        default=BillingPeriodChoices.MONTHLY,
        help_text=(
            "Cadence at which recurring_cost is charged. Drives cost-analytics "
            "normalization (monthly burn rate, annual run rate, renewal forecast)."
        ),
    )
    one_time_cost = models.DecimalField(
        max_digits=12,
        decimal_places=2,
        default=Decimal("0.00"),
        help_text="Setup / activation / migration fees that aren't recurring.",
    )
    currency = models.CharField(
        max_length=3,
        default="USD",
        help_text="ISO 4217 currency code (e.g. USD, EUR, GBP). v1 doesn't "
        "convert between currencies — totals across mixed-currency contracts "
        "are the operator's problem.",
    )
    description = models.CharField(max_length=200, blank=True)
    comments = models.TextField(blank=True)

    # --- Phase 7 SLA / contract-shape structure ---
    contract_type = models.CharField(
        max_length=30,
        choices=ContractTypeChoices,
        blank=True,
        help_text="Category of agreement (drives default renewal priority and dashboard grouping).",
    )
    coverage_hours = models.CharField(
        max_length=30,
        choices=CoverageHoursChoices,
        blank=True,
        help_text="When the vendor is contractually available to take a support call.",
    )
    response_time = models.CharField(
        max_length=30,
        choices=ResponseTimeChoices,
        blank=True,
        help_text="Vendor's contractual response-time SLA.",
    )
    restoration_time = models.CharField(
        max_length=30,
        choices=RestorationTimeChoices,
        blank=True,
        help_text="Vendor's contractual time-to-restore SLA. Distinct from response time.",
    )
    notice_period_days = models.PositiveSmallIntegerField(
        null=True,
        blank=True,
        help_text=(
            "Days before end_date by which a cancellation notice must be served (e.g. 60). "
            "Used by the renewal Job to alert earlier."
        ),
    )
    auto_renew = models.BooleanField(
        default=False,
        help_text="Does this contract auto-renew if no notice is served? Affects renewal-Job urgency.",
    )
    term_months = models.PositiveSmallIntegerField(
        null=True,
        blank=True,
        help_text="Original term length in months (12, 24, 36...). Null = perpetual or month-to-month.",
    )
    # --- end Phase 7 ---

    # Required by Nautobot's natural_slug / natural_key machinery (used by
    # detail views, REST API URL stability, and import/export matching).
    # Neither `name` nor `contract_number` is unique on its own (yearly
    # renewals reuse names; numbers are vendor-supplied and may collide
    # across providers), so we declare the (provider, name) pair as the
    # natural key — without imposing a DB-level UniqueConstraint that
    # would block legitimate duplicates. This is a Nautobot-level attribute,
    # NOT a Django Meta attribute (Django's Meta rejects unknown keys).
    natural_key_field_names = ["name", "provider"]

    class Meta:
        """Model metadata."""

        ordering = ["name", "-end_date"]
        constraints = [
            models.CheckConstraint(
                condition=models.Q(end_date__gte=models.F("start_date")),
                name="nbcm_contract_end_after_start",
            ),
        ]

    def __str__(self):
        """Render with the contract number when present."""
        if self.contract_number:
            return f"{self.name} ({self.contract_number})"
        return self.name

    @property
    def is_expired(self):
        """True if the contract's end_date has already passed."""
        return self.end_date < date.today()

    def is_expiring_within(self, days):
        """True if the contract expires within ``days`` from today (inclusive of today)."""
        today = date.today()
        return today <= self.end_date <= today + timedelta(days=days)

is_expired property

True if the contract's end_date has already passed.

Meta

Model metadata.

Source code in src/nautobot_contract_models/models/contract.py
class Meta:
    """Model metadata."""

    ordering = ["name", "-end_date"]
    constraints = [
        models.CheckConstraint(
            condition=models.Q(end_date__gte=models.F("start_date")),
            name="nbcm_contract_end_after_start",
        ),
    ]

__str__()

Render with the contract number when present.

Source code in src/nautobot_contract_models/models/contract.py
def __str__(self):
    """Render with the contract number when present."""
    if self.contract_number:
        return f"{self.name} ({self.contract_number})"
    return self.name

is_expiring_within(days)

True if the contract expires within days from today (inclusive of today).

Source code in src/nautobot_contract_models/models/contract.py
def is_expiring_within(self, days):
    """True if the contract expires within ``days`` from today (inclusive of today)."""
    today = date.today()
    return today <= self.end_date <= today + timedelta(days=days)

nautobot_contract_models.models.provider.ServiceProvider

Bases: PrimaryModel

A vendor or counterparty that provides services under one or more Contracts.

Use Nautobot's Contact framework (associated_contacts) to track named individuals (account manager, support rep, billing contact). The fields here are vendor-level — the portal you log into, the main support line — not contact-of-contact metadata.

Source code in src/nautobot_contract_models/models/provider.py
@extras_features(
    "custom_fields",
    "custom_links",
    "custom_validators",
    "export_templates",
    "graphql",
    "relationships",
    "webhooks",
)
class ServiceProvider(PrimaryModel):
    """A vendor or counterparty that provides services under one or more Contracts.

    Use Nautobot's Contact framework (``associated_contacts``) to track named
    individuals (account manager, support rep, billing contact). The fields
    here are *vendor-level* — the portal you log into, the main support line —
    not contact-of-contact metadata.
    """

    name = models.CharField(
        max_length=255,
        unique=True,
        help_text="Vendor display name (must be unique across the install).",
    )
    account_number = models.CharField(
        max_length=100,
        blank=True,
        help_text="Customer / account number assigned by the vendor.",
    )
    portal_url = models.URLField(
        blank=True,
        help_text="URL of the vendor's customer portal where you log in to manage the account.",
    )
    support_phone = models.CharField(
        max_length=50,
        blank=True,
        help_text="Primary support phone number (free-form to accommodate international formats).",
    )
    description = models.CharField(max_length=200, blank=True)
    comments = models.TextField(blank=True)

    class Meta:
        """Model metadata."""

        verbose_name = "Service Provider"
        verbose_name_plural = "Service Providers"
        ordering = ["name"]

    def __str__(self):
        """Render as the vendor's display name."""
        return self.name

Meta

Model metadata.

Source code in src/nautobot_contract_models/models/provider.py
class Meta:
    """Model metadata."""

    verbose_name = "Service Provider"
    verbose_name_plural = "Service Providers"
    ordering = ["name"]

__str__()

Render as the vendor's display name.

Source code in src/nautobot_contract_models/models/provider.py
def __str__(self):
    """Render as the vendor's display name."""
    return self.name

nautobot_contract_models.models.invoice.Invoice

Bases: PrimaryModel

One invoice / billing line belonging to a Contract.

Granularity: one row = one billing period. v1 doesn't model line-item breakdowns within an invoice — if an operator needs that, they add custom fields or build their own model.

Source code in src/nautobot_contract_models/models/invoice.py
@extras_features(
    "custom_fields",
    "custom_links",
    "custom_validators",
    "export_templates",
    "graphql",
    "relationships",
    "statuses",
    "webhooks",
)
class Invoice(PrimaryModel):
    """One invoice / billing line belonging to a Contract.

    Granularity: one row = one billing period. v1 doesn't model line-item
    breakdowns within an invoice — if an operator needs that, they add custom
    fields or build their own model.
    """

    contract = models.ForeignKey(
        to="nautobot_contract_models.Contract",
        on_delete=models.CASCADE,
        related_name="invoices",
        help_text="Owning contract. CASCADE — invoices can't exist orphaned from a contract.",
    )
    invoice_number = models.CharField(
        max_length=100,
        help_text="Vendor-supplied invoice number (must be unique within the owning contract).",
    )
    period_start = models.DateField(help_text="First day of the billing period this invoice covers.")
    period_end = models.DateField(help_text="Last day of the billing period this invoice covers.")
    invoice_date = models.DateField(help_text="Date the invoice was issued.")
    paid_date = models.DateField(
        null=True,
        blank=True,
        help_text="Date the invoice was paid. Null while still outstanding.",
    )
    total_amount = models.DecimalField(
        max_digits=12,
        decimal_places=2,
        default=Decimal("0.00"),
    )
    currency = models.CharField(
        max_length=3,
        default="USD",
        help_text="ISO 4217 currency code. Should match the parent Contract; "
        "kept as a field rather than a property so currency conversions over "
        "time can be modeled if needed.",
    )
    # related_name namespace-prefixed defensively, matching Contract.status —
    # see contract.py for the collision rationale. DLC has no Invoice equivalent
    # today but other plugins may.
    status = StatusField(blank=False, null=False, related_name="contract_models_invoices")
    description = models.CharField(max_length=200, blank=True)
    comments = models.TextField(blank=True)

    class Meta:
        """Model metadata."""

        ordering = ["-invoice_date", "invoice_number"]
        constraints = [
            models.UniqueConstraint(
                fields=["contract", "invoice_number"],
                name="nbcm_invoice_unique_per_contract",
            ),
            models.CheckConstraint(
                condition=models.Q(period_end__gte=models.F("period_start")),
                name="nbcm_invoice_period_end_after_start",
            ),
        ]

    def __str__(self):
        """Render as ``<invoice_number> on <contract>``."""
        return f"{self.invoice_number} on {self.contract.name}"

    @property
    def is_paid(self):
        """True once a paid_date has been recorded."""
        return self.paid_date is not None

is_paid property

True once a paid_date has been recorded.

Meta

Model metadata.

Source code in src/nautobot_contract_models/models/invoice.py
class Meta:
    """Model metadata."""

    ordering = ["-invoice_date", "invoice_number"]
    constraints = [
        models.UniqueConstraint(
            fields=["contract", "invoice_number"],
            name="nbcm_invoice_unique_per_contract",
        ),
        models.CheckConstraint(
            condition=models.Q(period_end__gte=models.F("period_start")),
            name="nbcm_invoice_period_end_after_start",
        ),
    ]

__str__()

Render as <invoice_number> on <contract>.

Source code in src/nautobot_contract_models/models/invoice.py
def __str__(self):
    """Render as ``<invoice_number> on <contract>``."""
    return f"{self.invoice_number} on {self.contract.name}"

nautobot_contract_models.models.assignment.ContractAssignment

Bases: PrimaryModel

Link a :class:Contract to any Nautobot object that has a UUID PK.

Why generic FK rather than a typed M2M? We want one model to handle Contract-to-Device, Contract-to-Circuit, Contract-to-VirtualMachine, Contract-to-PowerFeed, etc. without a per-target-type table. Django's ContentType + GenericForeignKey gives us that. Operators can attach a Contract to anything in the Nautobot ORM — we don't have to enumerate the supported targets up front.

Subclasses :class:PrimaryModel (matching Nautobot's own :class:ContactAssociation, which is the canonical GFK-link model). The full PrimaryModel surface — ChangeLog, custom fields, tags, Relationships, dynamic groups — applies. ChangeLog in particular matters: operators want to see "who linked this Contract to that Device, and when".

Phase-2's earlier choice of BaseModel was wrong for this reason: NautobotModelForm expects RelationshipModelMixin on the model (which BaseModel doesn't include), so the create form 500s on instance.get_relationships(). Promoting to PrimaryModel fixes the immediate bug AND aligns with how Nautobot itself models GFK links.

Source code in src/nautobot_contract_models/models/assignment.py
@extras_features(
    "custom_fields",
    "custom_links",
    "custom_validators",
    "export_templates",
    "graphql",
    "relationships",
    "webhooks",
)
class ContractAssignment(PrimaryModel):
    """Link a :class:`Contract` to *any* Nautobot object that has a UUID PK.

    Why generic FK rather than a typed M2M?
        We want one model to handle Contract-to-Device, Contract-to-Circuit,
        Contract-to-VirtualMachine, Contract-to-PowerFeed, etc. without a
        per-target-type table. Django's ContentType + GenericForeignKey gives
        us that. Operators can attach a Contract to anything in the Nautobot
        ORM — we don't have to enumerate the supported targets up front.

    Subclasses :class:`PrimaryModel` (matching Nautobot's own
    :class:`ContactAssociation`, which is the canonical GFK-link model). The
    full PrimaryModel surface — ChangeLog, custom fields, tags, Relationships,
    dynamic groups — applies. ChangeLog in particular matters: operators want
    to see "who linked this Contract to that Device, and when".

    Phase-2's earlier choice of ``BaseModel`` was wrong for this reason:
    ``NautobotModelForm`` expects ``RelationshipModelMixin`` on the model
    (which BaseModel doesn't include), so the create form 500s on
    ``instance.get_relationships()``. Promoting to PrimaryModel fixes the
    immediate bug AND aligns with how Nautobot itself models GFK links.
    """

    contract = models.ForeignKey(
        to="nautobot_contract_models.Contract",
        on_delete=models.CASCADE,
        related_name="assignments",
    )
    content_type = models.ForeignKey(
        to=ContentType,
        on_delete=models.PROTECT,
        related_name="+",
        help_text="ContentType of the target object.",
    )
    object_id = models.UUIDField(help_text="UUID PK of the target object — every Nautobot model uses UUID PKs.")
    object = GenericForeignKey("content_type", "object_id")

    # --- Phase 7 per-assignment scope ---
    coverage_start = models.DateField(
        null=True,
        blank=True,
        help_text="When coverage of this target begins. Null = follows the contract's start_date.",
    )
    coverage_end = models.DateField(
        null=True,
        blank=True,
        help_text=(
            "When coverage of this target ends. Null = follows the contract's end_date. "
            "Useful for mid-term additions/removals."
        ),
    )
    scope_notes = models.CharField(
        max_length=255,
        blank=True,
        help_text="Optional scope qualifier (e.g. 'chassis only, not modules'; 'firmware updates excluded').",
    )
    is_primary = models.BooleanField(
        default=False,
        help_text=(
            "When the same target is covered by multiple contracts, this flag picks "
            "the primary one for display / on-call routing."
        ),
    )
    # --- end Phase 7 ---

    class Meta:
        """Model metadata."""

        ordering = ["contract", "content_type", "object_id"]
        constraints = [
            models.UniqueConstraint(
                fields=["contract", "content_type", "object_id"],
                name="nbcm_assignment_unique_target",
            ),
        ]
        indexes = [
            models.Index(fields=["content_type", "object_id"]),
        ]

    def __str__(self):
        """Render as ``<contract> -> <target>``; degrade gracefully if the GFK target is missing."""
        target = self.object if self.object_id else None
        return f"{self.contract.name} -> {target or 'unresolved'}"

Meta

Model metadata.

Source code in src/nautobot_contract_models/models/assignment.py
class Meta:
    """Model metadata."""

    ordering = ["contract", "content_type", "object_id"]
    constraints = [
        models.UniqueConstraint(
            fields=["contract", "content_type", "object_id"],
            name="nbcm_assignment_unique_target",
        ),
    ]
    indexes = [
        models.Index(fields=["content_type", "object_id"]),
    ]

__str__()

Render as <contract> -> <target>; degrade gracefully if the GFK target is missing.

Source code in src/nautobot_contract_models/models/assignment.py
def __str__(self):
    """Render as ``<contract> -> <target>``; degrade gracefully if the GFK target is missing."""
    target = self.object if self.object_id else None
    return f"{self.contract.name} -> {target or 'unresolved'}"

nautobot_contract_models.models.attachment.ContractAttachment

Bases: _AttachmentBase

A single file uploaded against a :class:Contract.

Common contents: the signed contract PDF itself, vendor SOWs / proposals, renewal-letter scans, addenda. Operators usually upload one or two and reference them rarely — the typical access pattern is "I need to see the actual signed agreement".

Files land at contract_attachments/YYYY/MM/<filename>. Same volume, same backup discipline as :class:InvoiceAttachment.

Source code in src/nautobot_contract_models/models/attachment.py
@extras_features(*_ATTACHMENT_EXTRAS)
class ContractAttachment(_AttachmentBase):
    """A single file uploaded against a :class:`Contract`.

    Common contents: the signed contract PDF itself, vendor SOWs / proposals,
    renewal-letter scans, addenda. Operators usually upload one or two and
    reference them rarely — the typical access pattern is "I need to see the
    actual signed agreement".

    Files land at ``contract_attachments/YYYY/MM/<filename>``. Same volume,
    same backup discipline as :class:`InvoiceAttachment`.
    """

    contract = models.ForeignKey(
        to="nautobot_contract_models.Contract",
        on_delete=models.CASCADE,
        related_name="attachments",
        help_text="Owning contract. CASCADE — attachments can't outlive their contract.",
    )
    file = models.FileField(
        upload_to="contract_attachments/%Y/%m/",
        help_text="The uploaded file. Typically a PDF, but any file type is allowed.",
    )

    natural_key_field_names = ["contract", "file"]

    class Meta(_AttachmentBase.Meta):
        """Concrete metadata."""

        abstract = False

    def __str__(self):
        """Render as ``<filename> (<contract_name>)``."""
        return f"{self.filename} ({self.contract.name})"

Meta

Bases: Meta

Concrete metadata.

Source code in src/nautobot_contract_models/models/attachment.py
class Meta(_AttachmentBase.Meta):
    """Concrete metadata."""

    abstract = False

__str__()

Render as <filename> (<contract_name>).

Source code in src/nautobot_contract_models/models/attachment.py
def __str__(self):
    """Render as ``<filename> (<contract_name>)``."""
    return f"{self.filename} ({self.contract.name})"

nautobot_contract_models.models.attachment.InvoiceAttachment

Bases: _AttachmentBase

A single file uploaded against an :class:Invoice.

The motivating use case: vendors send invoices as PDFs. Operators want to attach the actual PDF to the database row so future-them (or auditors) can see what the original looked like, not just the typed-in numbers.

Files land at invoice_attachments/YYYY/MM/<filename> under Nautobot's MEDIA_ROOT and serve at /media/invoice_attachments/.... The nautobot-media Docker volume persists them across restarts; production deployments need a separate backup strategy for that volume since DB dumps don't include media files.

Source code in src/nautobot_contract_models/models/attachment.py
@extras_features(*_ATTACHMENT_EXTRAS)
class InvoiceAttachment(_AttachmentBase):
    """A single file uploaded against an :class:`Invoice`.

    The motivating use case: vendors send invoices as PDFs. Operators want to
    attach the actual PDF to the database row so future-them (or auditors)
    can see what the original looked like, not just the typed-in numbers.

    Files land at ``invoice_attachments/YYYY/MM/<filename>`` under Nautobot's
    ``MEDIA_ROOT`` and serve at ``/media/invoice_attachments/...``. The
    ``nautobot-media`` Docker volume persists them across restarts; production
    deployments need a separate backup strategy for that volume since DB
    dumps don't include media files.
    """

    invoice = models.ForeignKey(
        to="nautobot_contract_models.Invoice",
        on_delete=models.CASCADE,
        related_name="attachments",
        help_text="Owning invoice. CASCADE — attachments can't outlive their invoice.",
    )
    file = models.FileField(
        upload_to="invoice_attachments/%Y/%m/",
        help_text="The uploaded file. Typically a PDF, but any file type is allowed.",
    )

    natural_key_field_names = ["invoice", "file"]

    class Meta(_AttachmentBase.Meta):
        """Concrete metadata."""

        abstract = False

    def __str__(self):
        """Render as ``<filename> (<invoice_number>)``."""
        return f"{self.filename} ({self.invoice.invoice_number})"

Meta

Bases: Meta

Concrete metadata.

Source code in src/nautobot_contract_models/models/attachment.py
class Meta(_AttachmentBase.Meta):
    """Concrete metadata."""

    abstract = False

__str__()

Render as <filename> (<invoice_number>).

Source code in src/nautobot_contract_models/models/attachment.py
def __str__(self):
    """Render as ``<filename> (<invoice_number>)``."""
    return f"{self.filename} ({self.invoice.invoice_number})"

nautobot_contract_models.models.snapshot.CostSnapshot

Bases: BaseModel

One per-currency aggregate of fleet contract costs on one date.

Source code in src/nautobot_contract_models/models/snapshot.py
@extras_features("graphql")
class CostSnapshot(BaseModel):
    """One per-currency aggregate of fleet contract costs on one date."""

    snapshot_date = models.DateField(
        help_text="The date this snapshot represents. Multiple currencies on the same date = multiple rows.",
    )
    currency = models.CharField(
        max_length=3,
        help_text="ISO 4217 currency code. Snapshots are per-currency; we never sum across currencies.",
    )
    monthly_burn = models.DecimalField(
        max_digits=14,
        decimal_places=2,
        default=Decimal("0.00"),
        help_text="Sum of monthly_cost across active contracts in this currency on snapshot_date.",
    )
    renewal_90d = models.DecimalField(
        max_digits=14,
        decimal_places=2,
        default=Decimal("0.00"),
        help_text="Total renewal cost in the 90-day forward window from snapshot_date, this currency.",
    )
    active_contract_count = models.PositiveIntegerField(
        default=0,
        help_text="Number of active contracts in this currency on snapshot_date.",
    )
    coverage_gap_count = models.PositiveIntegerField(
        default=0,
        null=True,
        blank=True,
        help_text=(
            "Devices with no direct contract assignment as of snapshot_date. "
            "Stored on the first per-date snapshot only — null on others — "
            "since the gap count is currency-agnostic."
        ),
    )

    natural_key_field_names = ["snapshot_date", "currency"]

    class Meta:
        """Model metadata."""

        ordering = ["-snapshot_date", "currency"]
        constraints = [
            models.UniqueConstraint(
                fields=["snapshot_date", "currency"],
                name="nbcm_costsnapshot_unique_per_date_currency",
            ),
        ]
        indexes = [
            models.Index(fields=["-snapshot_date", "currency"]),
        ]

    def __str__(self):
        """Render as ``YYYY-MM-DD CUR``."""
        return f"{self.snapshot_date} {self.currency}"

Meta

Model metadata.

Source code in src/nautobot_contract_models/models/snapshot.py
class Meta:
    """Model metadata."""

    ordering = ["-snapshot_date", "currency"]
    constraints = [
        models.UniqueConstraint(
            fields=["snapshot_date", "currency"],
            name="nbcm_costsnapshot_unique_per_date_currency",
        ),
    ]
    indexes = [
        models.Index(fields=["-snapshot_date", "currency"]),
    ]

__str__()

Render as YYYY-MM-DD CUR.

Source code in src/nautobot_contract_models/models/snapshot.py
def __str__(self):
    """Render as ``YYYY-MM-DD CUR``."""
    return f"{self.snapshot_date} {self.currency}"