2021-08-29 15:33:29 +02:00
|
|
|
from typing import Any, Dict, Optional, Union
|
2018-09-25 14:02:43 +02:00
|
|
|
|
2021-08-29 15:33:29 +02:00
|
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
2018-09-25 14:02:43 +02:00
|
|
|
from django.db import models
|
2023-05-30 05:12:51 +02:00
|
|
|
from django.db.models import CASCADE, Q
|
2018-09-25 14:02:43 +02:00
|
|
|
|
2021-07-09 19:56:55 +02:00
|
|
|
from zerver.models import Realm, UserProfile
|
2021-11-20 14:50:14 +01:00
|
|
|
from zilencer.models import RemoteZulipServer
|
2018-09-25 14:02:43 +02:00
|
|
|
|
2020-06-11 00:54:34 +02:00
|
|
|
|
2018-09-25 14:02:43 +02:00
|
|
|
class Customer(models.Model):
|
2021-06-03 13:39:18 +02:00
|
|
|
"""
|
|
|
|
This model primarily serves to connect a Realm with
|
|
|
|
the corresponding Stripe customer object for payment purposes
|
|
|
|
and the active plan, if any.
|
|
|
|
"""
|
|
|
|
|
2022-08-15 19:10:58 +02:00
|
|
|
realm = models.OneToOneField(Realm, on_delete=CASCADE, null=True)
|
|
|
|
remote_server = models.OneToOneField(RemoteZulipServer, on_delete=CASCADE, null=True)
|
|
|
|
stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)
|
|
|
|
sponsorship_pending = models.BooleanField(default=False)
|
2019-01-25 02:14:07 +01:00
|
|
|
# A percentage, like 85.
|
2022-08-15 19:10:58 +02:00
|
|
|
default_discount = models.DecimalField(decimal_places=4, max_digits=7, null=True)
|
2021-06-17 16:25:40 +02:00
|
|
|
# Some non-profit organizations on manual license management pay
|
|
|
|
# only for their paid employees. We don't prevent these
|
|
|
|
# organizations from adding more users than the number of licenses
|
|
|
|
# they purchased.
|
2023-04-10 20:23:12 +02:00
|
|
|
exempt_from_license_number_check = models.BooleanField(default=False)
|
2018-09-25 14:02:43 +02:00
|
|
|
|
2023-05-30 05:12:51 +02:00
|
|
|
class Meta:
|
|
|
|
constraints = [
|
|
|
|
models.CheckConstraint(
|
|
|
|
check=Q(realm__isnull=False) ^ Q(remote_server__isnull=False),
|
|
|
|
name="cloud_xor_self_hosted",
|
|
|
|
)
|
|
|
|
]
|
|
|
|
|
2023-04-12 22:40:35 +02:00
|
|
|
def __str__(self) -> str:
|
|
|
|
return f"{self.realm!r} {self.stripe_customer_id}"
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-03-23 13:35:04 +01:00
|
|
|
def get_customer_by_realm(realm: Realm) -> Optional[Customer]:
|
|
|
|
return Customer.objects.filter(realm=realm).first()
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-08-29 15:33:29 +02:00
|
|
|
class Event(models.Model):
|
|
|
|
stripe_event_id = models.CharField(max_length=255)
|
|
|
|
|
|
|
|
type = models.CharField(max_length=255)
|
|
|
|
|
|
|
|
RECEIVED = 1
|
|
|
|
EVENT_HANDLER_STARTED = 30
|
|
|
|
EVENT_HANDLER_FAILED = 40
|
|
|
|
EVENT_HANDLER_SUCCEEDED = 50
|
|
|
|
status = models.SmallIntegerField(default=RECEIVED)
|
|
|
|
|
|
|
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
|
|
object_id = models.PositiveIntegerField(db_index=True)
|
|
|
|
content_object = GenericForeignKey("content_type", "object_id")
|
|
|
|
|
|
|
|
handler_error = models.JSONField(default=None, null=True)
|
|
|
|
|
|
|
|
def get_event_handler_details_as_dict(self) -> Dict[str, Any]:
|
|
|
|
details_dict = {}
|
|
|
|
details_dict["status"] = {
|
|
|
|
Event.RECEIVED: "not_started",
|
|
|
|
Event.EVENT_HANDLER_STARTED: "started",
|
|
|
|
Event.EVENT_HANDLER_FAILED: "failed",
|
|
|
|
Event.EVENT_HANDLER_SUCCEEDED: "succeeded",
|
|
|
|
}[self.status]
|
|
|
|
if self.handler_error:
|
|
|
|
details_dict["error"] = self.handler_error
|
|
|
|
return details_dict
|
|
|
|
|
|
|
|
|
|
|
|
def get_last_associated_event_by_type(
|
|
|
|
content_object: Union["PaymentIntent", "Session"], event_type: str
|
|
|
|
) -> Optional[Event]:
|
|
|
|
content_type = ContentType.objects.get_for_model(type(content_object))
|
|
|
|
return Event.objects.filter(
|
|
|
|
content_type=content_type, object_id=content_object.id, type=event_type
|
|
|
|
).last()
|
|
|
|
|
|
|
|
|
|
|
|
class Session(models.Model):
|
2022-08-15 19:10:58 +02:00
|
|
|
customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
|
|
|
stripe_session_id = models.CharField(max_length=255, unique=True)
|
2021-08-29 15:33:29 +02:00
|
|
|
payment_intent = models.ForeignKey("PaymentIntent", null=True, on_delete=CASCADE)
|
|
|
|
|
|
|
|
UPGRADE_FROM_BILLING_PAGE = 1
|
|
|
|
RETRY_UPGRADE_WITH_ANOTHER_PAYMENT_METHOD = 10
|
|
|
|
FREE_TRIAL_UPGRADE_FROM_BILLING_PAGE = 20
|
|
|
|
FREE_TRIAL_UPGRADE_FROM_ONBOARDING_PAGE = 30
|
|
|
|
CARD_UPDATE_FROM_BILLING_PAGE = 40
|
2022-08-15 19:10:58 +02:00
|
|
|
type = models.SmallIntegerField()
|
2021-08-29 15:33:29 +02:00
|
|
|
|
|
|
|
CREATED = 1
|
|
|
|
COMPLETED = 10
|
2022-08-15 19:10:58 +02:00
|
|
|
status = models.SmallIntegerField(default=CREATED)
|
2021-08-29 15:33:29 +02:00
|
|
|
|
|
|
|
def get_status_as_string(self) -> str:
|
|
|
|
return {Session.CREATED: "created", Session.COMPLETED: "completed"}[self.status]
|
|
|
|
|
|
|
|
def get_type_as_string(self) -> str:
|
|
|
|
return {
|
|
|
|
Session.UPGRADE_FROM_BILLING_PAGE: "upgrade_from_billing_page",
|
|
|
|
Session.RETRY_UPGRADE_WITH_ANOTHER_PAYMENT_METHOD: "retry_upgrade_with_another_payment_method",
|
|
|
|
Session.FREE_TRIAL_UPGRADE_FROM_BILLING_PAGE: "free_trial_upgrade_from_billing_page",
|
|
|
|
Session.FREE_TRIAL_UPGRADE_FROM_ONBOARDING_PAGE: "free_trial_upgrade_from_onboarding_page",
|
|
|
|
Session.CARD_UPDATE_FROM_BILLING_PAGE: "card_update_from_billing_page",
|
|
|
|
}[self.type]
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
session_dict: Dict[str, Any] = {}
|
|
|
|
|
|
|
|
session_dict["status"] = self.get_status_as_string()
|
|
|
|
session_dict["type"] = self.get_type_as_string()
|
|
|
|
if self.payment_intent:
|
|
|
|
session_dict["stripe_payment_intent_id"] = self.payment_intent.stripe_payment_intent_id
|
|
|
|
event = self.get_last_associated_event()
|
|
|
|
if event is not None:
|
|
|
|
session_dict["event_handler"] = event.get_event_handler_details_as_dict()
|
|
|
|
return session_dict
|
|
|
|
|
|
|
|
def get_last_associated_event(self) -> Optional[Event]:
|
|
|
|
if self.status == Session.CREATED:
|
|
|
|
return None
|
|
|
|
return get_last_associated_event_by_type(self, "checkout.session.completed")
|
|
|
|
|
|
|
|
|
|
|
|
class PaymentIntent(models.Model):
|
2022-08-15 19:10:58 +02:00
|
|
|
customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
|
|
|
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
|
2021-08-29 15:33:29 +02:00
|
|
|
|
|
|
|
REQUIRES_PAYMENT_METHOD = 1
|
|
|
|
REQUIRES_CONFIRMATION = 20
|
|
|
|
REQUIRES_ACTION = 30
|
|
|
|
PROCESSING = 40
|
|
|
|
REQUIRES_CAPTURE = 50
|
|
|
|
CANCELLED = 60
|
|
|
|
SUCCEEDED = 70
|
|
|
|
|
2022-08-15 19:10:58 +02:00
|
|
|
status = models.SmallIntegerField()
|
2021-08-29 15:33:29 +02:00
|
|
|
last_payment_error = models.JSONField(default=None, null=True)
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def get_status_integer_from_status_text(cls, status_text: str) -> int:
|
|
|
|
return getattr(cls, status_text.upper())
|
|
|
|
|
|
|
|
def get_status_as_string(self) -> str:
|
|
|
|
return {
|
|
|
|
PaymentIntent.REQUIRES_PAYMENT_METHOD: "requires_payment_method",
|
|
|
|
PaymentIntent.REQUIRES_CONFIRMATION: "requires_confirmation",
|
|
|
|
PaymentIntent.REQUIRES_ACTION: "requires_action",
|
|
|
|
PaymentIntent.PROCESSING: "processing",
|
|
|
|
PaymentIntent.REQUIRES_CAPTURE: "requires_capture",
|
|
|
|
PaymentIntent.CANCELLED: "cancelled",
|
|
|
|
PaymentIntent.SUCCEEDED: "succeeded",
|
|
|
|
}[self.status]
|
|
|
|
|
|
|
|
def get_last_associated_event(self) -> Optional[Event]:
|
|
|
|
if self.status == PaymentIntent.SUCCEEDED:
|
|
|
|
event_type = "payment_intent.succeeded"
|
|
|
|
elif self.status == PaymentIntent.REQUIRES_PAYMENT_METHOD:
|
|
|
|
event_type = "payment_intent.payment_failed"
|
|
|
|
else:
|
|
|
|
return None
|
|
|
|
return get_last_associated_event_by_type(self, event_type)
|
|
|
|
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
|
|
payment_intent_dict: Dict[str, Any] = {}
|
|
|
|
payment_intent_dict["status"] = self.get_status_as_string()
|
|
|
|
event = self.get_last_associated_event()
|
|
|
|
if self.last_payment_error:
|
|
|
|
payment_intent_dict["last_payment_error"] = self.last_payment_error
|
|
|
|
if event is not None:
|
|
|
|
payment_intent_dict["event_handler"] = event.get_event_handler_details_as_dict()
|
|
|
|
return payment_intent_dict
|
|
|
|
|
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
class CustomerPlan(models.Model):
|
2021-06-03 13:39:18 +02:00
|
|
|
"""
|
|
|
|
This is for storing most of the fiddly details
|
|
|
|
of the customer's plan.
|
|
|
|
"""
|
|
|
|
|
|
|
|
# A customer can only have one ACTIVE plan, but old, inactive plans
|
|
|
|
# are preserved to allow auditing - so there can be multiple
|
|
|
|
# CustomerPlan objects pointing to one Customer.
|
2022-08-15 19:10:58 +02:00
|
|
|
customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
2021-06-03 13:39:18 +02:00
|
|
|
|
2022-08-15 19:10:58 +02:00
|
|
|
automanage_licenses = models.BooleanField(default=False)
|
|
|
|
charge_automatically = models.BooleanField(default=False)
|
2021-06-08 12:43:44 +02:00
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
# Both of these are in cents. Exactly one of price_per_license or
|
|
|
|
# fixed_price should be set. fixed_price is only for manual deals, and
|
|
|
|
# can't be set via the self-serve billing system.
|
2022-08-15 19:10:58 +02:00
|
|
|
price_per_license = models.IntegerField(null=True)
|
|
|
|
fixed_price = models.IntegerField(null=True)
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2019-01-25 02:14:07 +01:00
|
|
|
# Discount that was applied. For display purposes only.
|
2022-08-15 19:10:58 +02:00
|
|
|
discount = models.DecimalField(decimal_places=4, max_digits=6, null=True)
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2021-06-17 16:25:40 +02:00
|
|
|
# Initialized with the time of plan creation. Used for calculating
|
|
|
|
# start of next billing cycle, next invoice date etc. This value
|
|
|
|
# should never be modified. The only exception is when we change
|
|
|
|
# the status of the plan from free trial to active and reset the
|
|
|
|
# billing_cycle_anchor.
|
2022-08-15 19:10:58 +02:00
|
|
|
billing_cycle_anchor = models.DateTimeField()
|
2021-06-17 16:25:40 +02:00
|
|
|
|
2018-12-12 23:23:15 +01:00
|
|
|
ANNUAL = 1
|
|
|
|
MONTHLY = 2
|
2022-08-15 19:10:58 +02:00
|
|
|
billing_schedule = models.SmallIntegerField()
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2021-06-17 16:25:40 +02:00
|
|
|
# The next date the billing system should go through ledger
|
|
|
|
# entries and create invoices for additional users or plan
|
|
|
|
# renewal. Since we use a daily cron job for invoicing, the
|
|
|
|
# invoice will be generated the first time the cron job runs after
|
|
|
|
# next_invoice_date.
|
2022-08-15 19:10:58 +02:00
|
|
|
next_invoice_date = models.DateTimeField(db_index=True, null=True)
|
2021-06-17 16:25:40 +02:00
|
|
|
|
|
|
|
# On next_invoice_date, we go through ledger entries that were
|
|
|
|
# created after invoiced_through and process them by generating
|
|
|
|
# invoices for any additional users and/or plan renewal. Once the
|
|
|
|
# invoice is generated, we update the value of invoiced_through
|
|
|
|
# and set it to the last ledger entry we processed.
|
2022-08-15 19:10:58 +02:00
|
|
|
invoiced_through = models.ForeignKey(
|
2021-02-12 08:20:45 +01:00
|
|
|
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2022-08-15 19:10:58 +02:00
|
|
|
end_date = models.DateTimeField(null=True)
|
2021-06-17 16:25:40 +02:00
|
|
|
|
2019-01-28 14:18:21 +01:00
|
|
|
DONE = 1
|
|
|
|
STARTED = 2
|
2020-06-15 20:09:24 +02:00
|
|
|
INITIAL_INVOICE_TO_BE_SENT = 3
|
2021-06-17 16:25:40 +02:00
|
|
|
# This status field helps ensure any errors encountered during the
|
|
|
|
# invoicing process do not leave our invoicing system in a broken
|
|
|
|
# state.
|
2022-08-15 19:10:58 +02:00
|
|
|
invoicing_status = models.SmallIntegerField(default=DONE)
|
2018-12-15 09:33:25 +01:00
|
|
|
|
|
|
|
STANDARD = 1
|
|
|
|
PLUS = 2 # not available through self-serve signup
|
|
|
|
ENTERPRISE = 10
|
2022-08-15 19:10:58 +02:00
|
|
|
tier = models.SmallIntegerField()
|
2018-12-15 09:33:25 +01:00
|
|
|
|
|
|
|
ACTIVE = 1
|
2019-04-10 19:43:16 +02:00
|
|
|
DOWNGRADE_AT_END_OF_CYCLE = 2
|
2020-04-23 20:10:15 +02:00
|
|
|
FREE_TRIAL = 3
|
2020-06-15 20:09:24 +02:00
|
|
|
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
|
2021-09-21 21:21:03 +02:00
|
|
|
SWITCH_NOW_FROM_STANDARD_TO_PLUS = 5
|
2019-04-10 19:43:16 +02:00
|
|
|
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
|
|
|
|
# There should be at most one live plan per customer.
|
|
|
|
LIVE_STATUS_THRESHOLD = 10
|
|
|
|
ENDED = 11
|
|
|
|
NEVER_STARTED = 12
|
2022-08-15 19:10:58 +02:00
|
|
|
status = models.SmallIntegerField(default=ACTIVE)
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2021-06-17 16:25:40 +02:00
|
|
|
# TODO maybe override setattr to ensure billing_cycle_anchor, etc
|
|
|
|
# are immutable.
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2020-07-03 20:14:31 +02:00
|
|
|
@property
|
|
|
|
def name(self) -> str:
|
|
|
|
return {
|
2022-02-05 08:29:54 +01:00
|
|
|
CustomerPlan.STANDARD: "Zulip Cloud Standard",
|
2021-02-12 08:20:45 +01:00
|
|
|
CustomerPlan.PLUS: "Zulip Plus",
|
|
|
|
CustomerPlan.ENTERPRISE: "Zulip Enterprise",
|
2020-07-03 20:14:31 +02:00
|
|
|
}[self.tier]
|
|
|
|
|
2020-07-03 20:21:13 +02:00
|
|
|
def get_plan_status_as_text(self) -> str:
|
|
|
|
return {
|
|
|
|
self.ACTIVE: "Active",
|
|
|
|
self.DOWNGRADE_AT_END_OF_CYCLE: "Scheduled for downgrade at end of cycle",
|
|
|
|
self.FREE_TRIAL: "Free trial",
|
|
|
|
self.ENDED: "Ended",
|
2021-02-12 08:19:30 +01:00
|
|
|
self.NEVER_STARTED: "Never started",
|
2020-07-03 20:21:13 +02:00
|
|
|
}[self.status]
|
2020-07-03 20:14:31 +02:00
|
|
|
|
2020-12-25 19:12:30 +01:00
|
|
|
def licenses(self) -> int:
|
2021-07-24 18:16:48 +02:00
|
|
|
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
|
|
|
|
assert ledger_entry is not None
|
|
|
|
return ledger_entry.licenses
|
2020-12-25 19:12:30 +01:00
|
|
|
|
2020-12-30 18:57:35 +01:00
|
|
|
def licenses_at_next_renewal(self) -> Optional[int]:
|
|
|
|
if self.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
|
|
|
return None
|
2021-07-24 18:16:48 +02:00
|
|
|
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
|
|
|
|
assert ledger_entry is not None
|
|
|
|
return ledger_entry.licenses_at_next_renewal
|
2020-12-30 18:57:35 +01:00
|
|
|
|
2020-11-11 14:02:47 +01:00
|
|
|
def is_free_trial(self) -> bool:
|
|
|
|
return self.status == CustomerPlan.FREE_TRIAL
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-03-24 14:14:03 +01:00
|
|
|
def get_current_plan_by_customer(customer: Customer) -> Optional[CustomerPlan]:
|
2019-04-10 19:43:16 +02:00
|
|
|
return CustomerPlan.objects.filter(
|
2021-02-12 08:19:30 +01:00
|
|
|
customer=customer, status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD
|
|
|
|
).first()
|
|
|
|
|
2018-12-12 23:23:15 +01:00
|
|
|
|
2020-03-24 14:22:27 +01:00
|
|
|
def get_current_plan_by_realm(realm: Realm) -> Optional[CustomerPlan]:
|
|
|
|
customer = get_customer_by_realm(realm)
|
|
|
|
if customer is None:
|
|
|
|
return None
|
|
|
|
return get_current_plan_by_customer(customer)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-12-28 07:20:30 +01:00
|
|
|
class LicenseLedger(models.Model):
|
2021-06-03 13:39:18 +02:00
|
|
|
"""
|
|
|
|
This table's purpose is to store the current, and historical,
|
|
|
|
count of "seats" purchased by the organization.
|
|
|
|
|
|
|
|
Because we want to keep historical data, when the purchased
|
|
|
|
seat count changes, a new LicenseLedger object is created,
|
|
|
|
instead of updating the old one. This lets us preserve
|
|
|
|
the entire history of how the seat count changes, which is
|
|
|
|
important for analytics as well as auditing and debugging
|
|
|
|
in case of issues.
|
|
|
|
"""
|
|
|
|
|
2022-08-15 19:10:58 +02:00
|
|
|
plan = models.ForeignKey(CustomerPlan, on_delete=CASCADE)
|
2021-06-17 16:25:40 +02:00
|
|
|
|
2018-12-28 07:20:30 +01:00
|
|
|
# Also True for the initial upgrade.
|
2022-08-15 19:10:58 +02:00
|
|
|
is_renewal = models.BooleanField(default=False)
|
2021-06-17 16:25:40 +02:00
|
|
|
|
2022-08-15 19:10:58 +02:00
|
|
|
event_time = models.DateTimeField()
|
2021-06-17 16:25:40 +02:00
|
|
|
|
|
|
|
# The number of licenses ("seats") purchased by the the organization at the time of ledger
|
|
|
|
# entry creation. Normally, to add a user the organization needs at least one spare license.
|
|
|
|
# Once a license is purchased, it is valid till the end of the billing period, irrespective
|
|
|
|
# of whether the license is used or not. So the value of licenses will never decrease for
|
|
|
|
# subsequent LicenseLedger entries in the same billing period.
|
2022-08-15 19:10:58 +02:00
|
|
|
licenses = models.IntegerField()
|
2021-06-17 16:25:40 +02:00
|
|
|
|
|
|
|
# The number of licenses the organization needs in the next billing cycle. The value of
|
|
|
|
# licenses_at_next_renewal can increase or decrease for subsequent LicenseLedger entries in
|
|
|
|
# the same billing period. For plans on automatic license management this value is usually
|
|
|
|
# equal to the number of activated users in the organization.
|
2022-08-15 19:10:58 +02:00
|
|
|
licenses_at_next_renewal = models.IntegerField(null=True)
|
2021-07-09 19:56:55 +02:00
|
|
|
|
|
|
|
|
|
|
|
class ZulipSponsorshipRequest(models.Model):
|
2022-08-15 19:10:58 +02:00
|
|
|
realm = models.ForeignKey(Realm, on_delete=CASCADE)
|
|
|
|
requested_by = models.ForeignKey(UserProfile, on_delete=CASCADE)
|
2021-07-09 19:56:55 +02:00
|
|
|
|
2022-08-15 19:10:58 +02:00
|
|
|
org_type = models.PositiveSmallIntegerField(
|
2021-07-09 19:56:55 +02:00
|
|
|
default=Realm.ORG_TYPES["unspecified"]["id"],
|
|
|
|
choices=[(t["id"], t["name"]) for t in Realm.ORG_TYPES.values()],
|
|
|
|
)
|
|
|
|
|
|
|
|
MAX_ORG_URL_LENGTH: int = 200
|
2022-08-15 19:10:58 +02:00
|
|
|
org_website = models.URLField(max_length=MAX_ORG_URL_LENGTH, blank=True, null=True)
|
2021-07-09 19:56:55 +02:00
|
|
|
|
2022-08-15 19:10:58 +02:00
|
|
|
org_description = models.TextField(default="")
|