Record where each new org came from, alert stale deals only for orgs in use, and offer industries on the phone's new-org screen (1.25.0)
This commit is contained in:
parent
f5c06542bc
commit
e91258eed5
22 changed files with 978 additions and 11 deletions
17
backend/common/migrations/0056_org_signup_source.py
Normal file
17
backend/common/migrations/0056_org_signup_source.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 6.1.2 on 2026-10-24 03:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("common", "0055_org_modules"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="org",
|
||||||
|
name="signup_source",
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -164,6 +164,11 @@ class Org(BaseModel):
|
||||||
# explicit confirmation; this flag only controls the default state.
|
# explicit confirmation; this flag only controls the default state.
|
||||||
auto_close_children_on_parent_close = models.BooleanField(default=False)
|
auto_close_children_on_parent_close = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Where the org came from, written once at creation by
|
||||||
|
# `common.signup_source.clean_signup_source`. Growth analytics only:
|
||||||
|
# NEVER used in a permission check or a queryset filter.
|
||||||
|
signup_source = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
# Vertical packs. Descriptive only: records which pack was applied at
|
# Vertical packs. Descriptive only: records which pack was applied at
|
||||||
# signup. NEVER used in a permission check or a queryset filter.
|
# signup. NEVER used in a permission check or a queryset filter.
|
||||||
vertical = models.CharField(
|
vertical = models.CharField(
|
||||||
|
|
|
||||||
41
backend/common/signup_source.py
Normal file
41
backend/common/signup_source.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""What an org's creation records about where it came from.
|
||||||
|
|
||||||
|
The web app keeps the first visit's campaign tags and referring host in a
|
||||||
|
cookie and sends them with the create; the API cleans them here and adds which
|
||||||
|
client created the org. A bad value is dropped rather than refused: this is
|
||||||
|
analytics, and it must never stop an org from being created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
TAG_KEYS = ("utm_source", "utm_medium", "utm_campaign")
|
||||||
|
MAX_TAG_LENGTH = 100
|
||||||
|
_HOST = re.compile(
|
||||||
|
r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The Flutter app's user agent. Web creates go through the SvelteKit server.
|
||||||
|
PHONE_USER_AGENT_PREFIX = "Dart/"
|
||||||
|
|
||||||
|
|
||||||
|
def clean_signup_source(raw, user_agent):
|
||||||
|
"""Return the dict to store on `Org.signup_source`.
|
||||||
|
|
||||||
|
Always has `client` ("phone" or "web"); adds each `utm_*` tag that is a
|
||||||
|
non-blank string (printable characters only, at most MAX_TAG_LENGTH) and
|
||||||
|
`referrer` when it is a bare host name. Anything else in `raw` is ignored.
|
||||||
|
"""
|
||||||
|
phone = (user_agent or "").startswith(PHONE_USER_AGENT_PREFIX)
|
||||||
|
source = {"client": "phone" if phone else "web"}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return source
|
||||||
|
for key in TAG_KEYS:
|
||||||
|
value = raw.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = "".join(ch for ch in value if ch.isprintable()).strip()
|
||||||
|
if value:
|
||||||
|
source[key] = value[:MAX_TAG_LENGTH]
|
||||||
|
referrer = raw.get("referrer")
|
||||||
|
if isinstance(referrer, str) and _HOST.match(referrer.strip().lower()):
|
||||||
|
source["referrer"] = referrer.strip().lower()
|
||||||
|
return source
|
||||||
121
backend/common/tests/test_signup_source.py
Normal file
121
backend/common/tests/test_signup_source.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""Where an org came from: what POST /api/org/ stores in `Org.signup_source`."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
|
from common.models import Org
|
||||||
|
from common.signup_source import MAX_TAG_LENGTH, clean_signup_source
|
||||||
|
|
||||||
|
URL = "/api/org/"
|
||||||
|
|
||||||
|
|
||||||
|
class TestClean:
|
||||||
|
def test_keeps_tags_and_a_host(self):
|
||||||
|
raw = {
|
||||||
|
"utm_source": "newsletter",
|
||||||
|
"utm_medium": "email",
|
||||||
|
"utm_campaign": "october",
|
||||||
|
"referrer": "news.example.com",
|
||||||
|
}
|
||||||
|
assert clean_signup_source(raw, "axios/1.20.0") == {"client": "web", **raw}
|
||||||
|
|
||||||
|
def test_the_phone_app_is_recognised_by_its_user_agent(self):
|
||||||
|
assert clean_signup_source({}, "Dart/3.12 (dart:io)") == {"client": "phone"}
|
||||||
|
|
||||||
|
def test_a_missing_user_agent_is_web(self):
|
||||||
|
assert clean_signup_source(None, None) == {"client": "web"}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", [None, "utm_source=x", ["x"], 42])
|
||||||
|
def test_anything_but_a_dict_records_only_the_client(self, raw):
|
||||||
|
assert clean_signup_source(raw, "") == {"client": "web"}
|
||||||
|
|
||||||
|
def test_unknown_keys_are_dropped(self):
|
||||||
|
source = clean_signup_source(
|
||||||
|
{"utm_source": "x", "email": "a@b.com", "org": "1"}, ""
|
||||||
|
)
|
||||||
|
assert source == {"client": "web", "utm_source": "x"}
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [None, 7, ["x"], {"a": 1}, " ", "\x00\x01"])
|
||||||
|
def test_a_tag_that_is_not_text_or_is_blank_is_dropped(self, value):
|
||||||
|
assert "utm_source" not in clean_signup_source({"utm_source": value}, "")
|
||||||
|
|
||||||
|
def test_a_long_tag_is_cut_and_control_characters_removed(self):
|
||||||
|
source = clean_signup_source({"utm_campaign": "a\nb" + "c" * 500}, "")
|
||||||
|
assert source["utm_campaign"].startswith("abc")
|
||||||
|
assert len(source["utm_campaign"]) == MAX_TAG_LENGTH
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"referrer",
|
||||||
|
[
|
||||||
|
"https://news.example.com/",
|
||||||
|
"news.example.com/path?token=secret",
|
||||||
|
"user@news.example.com",
|
||||||
|
"-bad.example.com",
|
||||||
|
"a" * 254,
|
||||||
|
"",
|
||||||
|
7,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_referrer_that_is_not_a_bare_host_is_dropped(self, referrer):
|
||||||
|
# A full URL can carry a path, a query or a token, so only a host name
|
||||||
|
# is ever stored.
|
||||||
|
assert "referrer" not in clean_signup_source({"referrer": referrer}, "")
|
||||||
|
|
||||||
|
def test_a_host_is_stored_lower_case(self):
|
||||||
|
assert clean_signup_source({"referrer": " News.Example.COM "}, "")[
|
||||||
|
"referrer"
|
||||||
|
] == ("news.example.com")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestCreate:
|
||||||
|
def test_the_source_is_stored_on_the_new_org(self, admin_client):
|
||||||
|
response = admin_client.post(
|
||||||
|
URL,
|
||||||
|
{
|
||||||
|
"name": "Sourced Org",
|
||||||
|
"signup_source": {"utm_source": "site", "referrer": "example.org"},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
org = Org.objects.get(name="Sourced Org")
|
||||||
|
assert org.signup_source == {
|
||||||
|
"client": "web",
|
||||||
|
"utm_source": "site",
|
||||||
|
"referrer": "example.org",
|
||||||
|
}
|
||||||
|
# Analytics only: the response does not echo it.
|
||||||
|
assert "signup_source" not in response.data["org"]
|
||||||
|
|
||||||
|
def test_an_org_created_without_one_still_records_the_client(self, admin_client):
|
||||||
|
response = admin_client.post(
|
||||||
|
URL,
|
||||||
|
{"name": "Plain Org"},
|
||||||
|
format="json",
|
||||||
|
HTTP_USER_AGENT="Dart/3.12 (dart:io)",
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert Org.objects.get(name="Plain Org").signup_source == {"client": "phone"}
|
||||||
|
|
||||||
|
def test_a_malformed_source_never_blocks_the_create(self, admin_client):
|
||||||
|
response = admin_client.post(
|
||||||
|
URL,
|
||||||
|
{
|
||||||
|
"name": "Junk Org",
|
||||||
|
"signup_source": {"referrer": "https://x.example/?t=1", "x": 1},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert Org.objects.get(name="Junk Org").signup_source == {"client": "web"}
|
||||||
|
|
||||||
|
def test_a_refused_create_stores_nothing(self, admin_client):
|
||||||
|
before = Org.objects.count()
|
||||||
|
response = admin_client.post(
|
||||||
|
URL,
|
||||||
|
{"name": "Bad & Name", "signup_source": {"utm_source": "x"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert Org.objects.count() == before
|
||||||
|
|
@ -18,6 +18,7 @@ from common.serializer import (
|
||||||
ProfileSerializer,
|
ProfileSerializer,
|
||||||
ShowOrganizationListSerializer,
|
ShowOrganizationListSerializer,
|
||||||
)
|
)
|
||||||
|
from common.signup_source import clean_signup_source
|
||||||
|
|
||||||
|
|
||||||
class OrgProfileCreateView(APIView):
|
class OrgProfileCreateView(APIView):
|
||||||
|
|
@ -42,7 +43,12 @@ class OrgProfileCreateView(APIView):
|
||||||
# auditing where org keys come from read the wrong line.
|
# auditing where org keys come from read the wrong line.
|
||||||
serializer = self.serializer_class(data=request.data)
|
serializer = self.serializer_class(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
org_obj = serializer.save()
|
org_obj = serializer.save(
|
||||||
|
signup_source=clean_signup_source(
|
||||||
|
request.data.get("signup_source"),
|
||||||
|
request.headers.get("User-Agent", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# now creating the profile
|
# now creating the profile
|
||||||
profile_obj = self.model2.objects.create(user=request.user, org=org_obj)
|
profile_obj = self.model2.objects.create(user=request.user, org=org_obj)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import logging
|
import logging
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -9,7 +10,7 @@ from django.template.loader import render_to_string
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from common.links import frontend_url
|
from common.links import frontend_url
|
||||||
from common.models import Org, Profile
|
from common.models import Activity, Org, Profile
|
||||||
from common.modules import module_is_available, module_on_since
|
from common.modules import module_is_available, module_on_since
|
||||||
from common.org_time import activate_org_timezone, org_timezone
|
from common.org_time import activate_org_timezone, org_timezone
|
||||||
from opportunity.models import Opportunity, SalesGoal
|
from opportunity.models import Opportunity, SalesGoal
|
||||||
|
|
@ -51,6 +52,23 @@ def send_email_to_assigned_user(recipients, opportunity_id, org_id):
|
||||||
msg.send()
|
msg.send()
|
||||||
|
|
||||||
|
|
||||||
|
# Stale-deal alerts go only to orgs someone used lately. A dormant org's admin
|
||||||
|
# would otherwise get the same email every day about deals nobody works.
|
||||||
|
STALE_ALERT_ACTIVE_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _used_recently(org):
|
||||||
|
"""Whether anyone worked in the org, or signed in to it, in the last
|
||||||
|
STALE_ALERT_ACTIVE_DAYS days. Runs under the org's RLS context."""
|
||||||
|
since = timezone.now() - timedelta(days=STALE_ALERT_ACTIVE_DAYS)
|
||||||
|
return (
|
||||||
|
Activity.objects.filter(org=org, created_at__gte=since).exists()
|
||||||
|
or Profile.objects.filter(
|
||||||
|
org=org, is_active=True, user__last_login__gte=since
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def check_stale_opportunities():
|
def check_stale_opportunities():
|
||||||
"""Daily task: find rotten deals across all orgs and send email alerts."""
|
"""Daily task: find rotten deals across all orgs and send email alerts."""
|
||||||
|
|
@ -63,6 +81,8 @@ def check_stale_opportunities():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
_set_rls_context_safe(str(org.id))
|
_set_rls_context_safe(str(org.id))
|
||||||
|
if not _used_recently(org):
|
||||||
|
continue
|
||||||
|
|
||||||
stages = stage_index(org.id)
|
stages = stage_index(org.id)
|
||||||
rotten_q = aging_q(stages.values(), "red")
|
rotten_q = aging_q(stages.values(), "red")
|
||||||
|
|
@ -71,8 +91,9 @@ def check_stale_opportunities():
|
||||||
# Already rotten when Pipeline came back on: it went stale
|
# Already rotten when Pipeline came back on: it went stale
|
||||||
# while nobody could see it, so it is never alerted.
|
# while nobody could see it, so it is never alerted.
|
||||||
rotten_q &= ~aging_q(stages.values(), "red", since)
|
rotten_q &= ~aging_q(stages.values(), "red", since)
|
||||||
|
# A pack's sample deals are nobody's work, so they never alert.
|
||||||
rotten = Opportunity.objects.filter(
|
rotten = Opportunity.objects.filter(
|
||||||
rotten_q, org=org, is_active=True
|
rotten_q, org=org, is_active=True, is_sample=False
|
||||||
).select_related("org")
|
).select_related("org")
|
||||||
|
|
||||||
stale_opps = []
|
stale_opps = []
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,9 @@ class TestStaleDealsTask:
|
||||||
stage_changed_at=timezone.now() - timedelta(days=30),
|
stage_changed_at=timezone.now() - timedelta(days=30),
|
||||||
)
|
)
|
||||||
rotten.assigned_to.add(admin_profile)
|
rotten.assigned_to.add(admin_profile)
|
||||||
|
# Alerts reach only orgs used in the last 30 days.
|
||||||
|
admin_user.last_login = timezone.now()
|
||||||
|
admin_user.save(update_fields=["last_login"])
|
||||||
|
|
||||||
from opportunity.tasks import check_stale_opportunities
|
from opportunity.tasks import check_stale_opportunities
|
||||||
|
|
||||||
|
|
@ -303,3 +306,81 @@ class TestStaleDealsTask:
|
||||||
|
|
||||||
# Alert should not be called since no deals are stale
|
# Alert should not be called since no deals are stale
|
||||||
mock_alert.assert_not_called()
|
mock_alert.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStaleDealsReachOnlyRealWork:
|
||||||
|
"""Alerts skip a pack's sample deals and orgs nobody has used lately."""
|
||||||
|
|
||||||
|
def _rotten(self, org, user, profile, **fields):
|
||||||
|
deal = Opportunity.objects.create(
|
||||||
|
name=fields.pop("name", "Stale"),
|
||||||
|
stage="PROSPECTING",
|
||||||
|
org=org,
|
||||||
|
created_by=user,
|
||||||
|
stage_changed_at=timezone.now() - timedelta(days=30),
|
||||||
|
**fields,
|
||||||
|
)
|
||||||
|
deal.assigned_to.add(profile)
|
||||||
|
return deal
|
||||||
|
|
||||||
|
def _go_dormant(self, org, user):
|
||||||
|
from common.models import Activity
|
||||||
|
|
||||||
|
Activity.objects.filter(org=org).update(
|
||||||
|
created_at=timezone.now() - timedelta(days=60)
|
||||||
|
)
|
||||||
|
user.last_login = None
|
||||||
|
user.save(update_fields=["last_login"])
|
||||||
|
|
||||||
|
@patch("opportunity.tasks.send_stale_deals_alert")
|
||||||
|
def test_sample_deals_never_alert(
|
||||||
|
self, mock_alert, org_a, admin_user, admin_profile
|
||||||
|
):
|
||||||
|
from opportunity.tasks import check_stale_opportunities
|
||||||
|
|
||||||
|
self._rotten(org_a, admin_user, admin_profile, is_sample=True)
|
||||||
|
check_stale_opportunities()
|
||||||
|
mock_alert.assert_not_called()
|
||||||
|
|
||||||
|
@patch("opportunity.tasks.send_stale_deals_alert")
|
||||||
|
def test_an_org_nobody_used_for_30_days_is_not_alerted(
|
||||||
|
self, mock_alert, org_a, admin_user, admin_profile
|
||||||
|
):
|
||||||
|
from opportunity.tasks import check_stale_opportunities
|
||||||
|
|
||||||
|
self._rotten(org_a, admin_user, admin_profile)
|
||||||
|
self._go_dormant(org_a, admin_user)
|
||||||
|
check_stale_opportunities()
|
||||||
|
mock_alert.assert_not_called()
|
||||||
|
|
||||||
|
@patch("opportunity.tasks.send_stale_deals_alert")
|
||||||
|
def test_recent_work_counts_as_use(
|
||||||
|
self, mock_alert, org_a, admin_user, admin_profile
|
||||||
|
):
|
||||||
|
from common.models import Activity
|
||||||
|
from opportunity.tasks import check_stale_opportunities
|
||||||
|
|
||||||
|
deal = self._rotten(org_a, admin_user, admin_profile)
|
||||||
|
self._go_dormant(org_a, admin_user)
|
||||||
|
Activity.objects.create(
|
||||||
|
user=admin_profile,
|
||||||
|
org=org_a,
|
||||||
|
action="UPDATE",
|
||||||
|
entity_type="Opportunity",
|
||||||
|
entity_id=deal.id,
|
||||||
|
)
|
||||||
|
check_stale_opportunities()
|
||||||
|
assert mock_alert.called
|
||||||
|
|
||||||
|
@patch("opportunity.tasks.send_stale_deals_alert")
|
||||||
|
def test_a_recent_sign_in_counts_as_use(
|
||||||
|
self, mock_alert, org_a, admin_user, admin_profile
|
||||||
|
):
|
||||||
|
from opportunity.tasks import check_stale_opportunities
|
||||||
|
|
||||||
|
self._rotten(org_a, admin_user, admin_profile)
|
||||||
|
self._go_dormant(org_a, admin_user)
|
||||||
|
admin_user.last_login = timezone.now() - timedelta(days=3)
|
||||||
|
admin_user.save(update_fields=["last_login"])
|
||||||
|
check_stale_opportunities()
|
||||||
|
assert mock_alert.called
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ def clock():
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_in(user):
|
||||||
|
"""A sign-in now, so the stale-deal job counts the user's org as in use."""
|
||||||
|
user.last_login = timezone.now()
|
||||||
|
user.save(update_fields=["last_login"])
|
||||||
|
|
||||||
|
|
||||||
def _switch(org, actor, *off):
|
def _switch(org, actor, *off):
|
||||||
"""Leave exactly ``off`` switched off, through the one writer."""
|
"""Leave exactly ``off`` switched off, through the one writer."""
|
||||||
assert change_modules(org, actor, {"off": list(off), "order": []}) == {}
|
assert change_modules(org, actor, {"off": list(off), "order": []}) == {}
|
||||||
|
|
@ -106,12 +112,14 @@ def _won(org, user, profile, amount):
|
||||||
|
|
||||||
@patch("opportunity.tasks.send_stale_deals_alert")
|
@patch("opportunity.tasks.send_stale_deals_alert")
|
||||||
def test_stale_deal_alert_skips_an_org_with_deals_off(
|
def test_stale_deal_alert_skips_an_org_with_deals_off(
|
||||||
mock_alert, org_a, org_b, admin_user, admin_profile, user_b
|
mock_alert, org_a, org_b, admin_user, admin_profile, user_b, profile_b
|
||||||
):
|
):
|
||||||
_switch(org_a, admin_profile, "deals")
|
_switch(org_a, admin_profile, "deals")
|
||||||
_deal(org_a, admin_user, "Ours", 30)
|
_deal(org_a, admin_user, "Ours", 30)
|
||||||
with rls_org(org_b):
|
with rls_org(org_b):
|
||||||
_deal(org_b, user_b, "Theirs", 30)
|
_deal(org_b, user_b, "Theirs", 30)
|
||||||
|
_signed_in(admin_user)
|
||||||
|
_signed_in(user_b)
|
||||||
|
|
||||||
check_stale_opportunities()
|
check_stale_opportunities()
|
||||||
|
|
||||||
|
|
@ -127,6 +135,7 @@ def test_deals_that_went_stale_while_off_are_not_alerted(
|
||||||
_deal(org_a, admin_user, "Went stale while off", 30)
|
_deal(org_a, admin_user, "Went stale while off", 30)
|
||||||
_deal(org_a, admin_user, "Turns stale later", 15)
|
_deal(org_a, admin_user, "Turns stale later", 15)
|
||||||
_switch(org_a, admin_profile)
|
_switch(org_a, admin_profile)
|
||||||
|
_signed_in(admin_user)
|
||||||
|
|
||||||
check_stale_opportunities()
|
check_stale_opportunities()
|
||||||
assert _alerted(mock_alert) == []
|
assert _alerted(mock_alert) == []
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "django-crm"
|
name = "django-crm"
|
||||||
version = "1.24.0"
|
version = "1.25.0"
|
||||||
# The one-line summary PyPI shows under the project title and in search results,
|
# The one-line summary PyPI shows under the project title and in search results,
|
||||||
# so it carries the terms people actually search for ("open source CRM",
|
# so it carries the terms people actually search for ("open source CRM",
|
||||||
# "self-hosted CRM", "Django CRM") and names the modules rather than the
|
# "self-hosted CRM", "Django CRM") and names the modules rather than the
|
||||||
|
|
|
||||||
|
|
@ -704,7 +704,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-crm"
|
name = "django-crm"
|
||||||
version = "1.24.0"
|
version = "1.25.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cairocffi" },
|
{ name = "cairocffi" },
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,12 @@ an open stage, and `red` (stalled, rotting) at `ceil(expected_days * 1.5)` (`agi
|
||||||
`workflow.py`). `?rotten=true` and the `stalled_count` in `totals` use the same thresholds as
|
`workflow.py`). `?rotten=true` and the `stalled_count` in `totals` use the same thresholds as
|
||||||
`Opportunity.get_aging_status()`, as queryset cutoffs (`opportunity/stages.py`, `aging_q`).
|
`Opportunity.get_aging_status()`, as queryset cutoffs (`opportunity/stages.py`, `aging_q`).
|
||||||
|
|
||||||
|
The daily stale-deal email (`check_stale_opportunities` in `opportunity/tasks.py`, run by Celery
|
||||||
|
beat) uses the same `red` threshold. It goes to each stalled deal's assignees, or to the org's
|
||||||
|
admins when nobody is assigned. It skips a pack's sample deals (`is_sample`), and it skips an org
|
||||||
|
nobody has used in the last 30 days: no activity and no member sign-in, so a dormant org is not
|
||||||
|
sent the same email every day.
|
||||||
|
|
||||||
### Pipeline administration
|
### Pipeline administration
|
||||||
|
|
||||||
Reads are open to every member of the org; every write is admin-only (`403` otherwise). An id from
|
Reads are open to every member of the org; every write is admin-only (`403` otherwise). An id from
|
||||||
|
|
|
||||||
|
|
@ -127,3 +127,20 @@ directly in the serializer instead of relying on `full_clean()`, `opportunity/se
|
||||||
own comment. If you add a `clean()` method to a model, verify whether that model's `save()` calls
|
own comment. If you add a `clean()` method to a model, verify whether that model's `save()` calls
|
||||||
`full_clean()` before assuming the rule is enforced anywhere outside a unit test that calls
|
`full_clean()` before assuming the rule is enforced anywhere outside a unit test that calls
|
||||||
`full_clean()` directly.
|
`full_clean()` directly.
|
||||||
|
|
||||||
|
## Where an org came from
|
||||||
|
|
||||||
|
`Org.signup_source` records, once, how the org's creator arrived, for growth analytics. It is never
|
||||||
|
read by a permission check or a queryset filter.
|
||||||
|
|
||||||
|
- The web app's sign-in page stores the first visit's `utm_source`, `utm_medium` and
|
||||||
|
`utm_campaign` tags and the referring site's host name in an httpOnly `signup_source` cookie
|
||||||
|
(30 days). An existing cookie is kept, so the first visit wins. Google's redirect back to the
|
||||||
|
sign-in page and the app's own pages are never taken as a source.
|
||||||
|
- Creating an org sends that value with `POST /api/org/` as `signup_source`, and the cookie is
|
||||||
|
then deleted.
|
||||||
|
- The API keeps only those four keys, as short printable text and a bare host name (never a full
|
||||||
|
URL, whose path or query can carry tokens), and adds `client`: `phone` for the mobile app,
|
||||||
|
`web` otherwise. A malformed value is dropped and never blocks the create.
|
||||||
|
|
||||||
|
Nothing personal is stored: no address, no email and no full referring URL.
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,10 @@ through:
|
||||||
|
|
||||||
- **The UI**: the Settings → Organization page (for an existing org) and the organization
|
- **The UI**: the Settings → Organization page (for an existing org) and the organization
|
||||||
creation page (for a brand-new org) both offer a pack picker, calling the same backend endpoint.
|
creation page (for a brand-new org) both offer a pack picker, calling the same backend endpoint.
|
||||||
|
Both exist in the web app and the mobile app. On creation the picker asks "What kind of
|
||||||
|
business is this?" and defaults to "Skip for now". It is hidden when the pack list cannot be
|
||||||
|
loaded. A pack that fails to apply never undoes the new org; the mobile app says so and points
|
||||||
|
to Settings → Organization, where the pack can be applied again.
|
||||||
- **The API**: `POST /api/packs/<pack_id>/apply/`, restricted to an organization admin
|
- **The API**: `POST /api/packs/<pack_id>/apply/`, restricted to an organization admin
|
||||||
(`request.profile.role == "ADMIN"`); any authenticated member can list available packs via
|
(`request.profile.role == "ADMIN"`); any authenticated member can list available packs via
|
||||||
`GET /api/packs/`, but only an admin can apply one.
|
`GET /api/packs/`, but only an admin can apply one.
|
||||||
|
|
|
||||||
137
frontend/src/lib/server/signup-source-routes.test.js
Normal file
137
frontend/src/lib/server/signup-source-routes.test.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* The sign-in page remembers where a visitor came from, and creating an org
|
||||||
|
* sends it once. Route modules are imported by path, as in
|
||||||
|
* relay-callers.test.js.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { SIGNUP_SOURCE_COOKIE } from './signup-source.js';
|
||||||
|
|
||||||
|
vi.mock('axios', () => ({ default: { post: vi.fn(), get: vi.fn() } }));
|
||||||
|
vi.mock('#lib/server/packs.js', () => ({ listPacks: vi.fn(), applyPack: vi.fn() }));
|
||||||
|
|
||||||
|
const axios = (await import('axios')).default;
|
||||||
|
const login = await import('../../routes/(no-layout)/login/+page.server.js');
|
||||||
|
const newOrg = await import('../../routes/(no-layout)/org/new/+page.server.js');
|
||||||
|
|
||||||
|
/** @param {Record<string, string>} initial */
|
||||||
|
function jar(initial = {}) {
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const store = { ...initial };
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
get: vi.fn((/** @type {string} */ name) => store[name]),
|
||||||
|
set: vi.fn((/** @type {string} */ name, /** @type {string} */ value) => {
|
||||||
|
store[name] = value;
|
||||||
|
}),
|
||||||
|
delete: vi.fn((/** @type {string} */ name) => {
|
||||||
|
delete store[name];
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {() => unknown} run */
|
||||||
|
async function ignoringRedirect(run) {
|
||||||
|
try {
|
||||||
|
await run();
|
||||||
|
} catch (/** @type {any} */ err) {
|
||||||
|
if (!err?.status || err.status < 300 || err.status >= 400) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} url @param {Record<string, string>} headers */
|
||||||
|
function loadLogin(url, headers, cookies) {
|
||||||
|
return ignoringRedirect(() =>
|
||||||
|
login.load(
|
||||||
|
/** @type {any} */ ({
|
||||||
|
url: new URL(url),
|
||||||
|
request: new Request(url, { headers }),
|
||||||
|
cookies,
|
||||||
|
getClientAddress: () => '198.51.100.7'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrg(cookies) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('org_name', 'Acme');
|
||||||
|
return ignoringRedirect(() =>
|
||||||
|
newOrg.actions.default(
|
||||||
|
/** @type {any} */ ({
|
||||||
|
request: new Request('http://app.test/org/new', { method: 'POST', body }),
|
||||||
|
cookies,
|
||||||
|
locals: { user: { id: 'u' } },
|
||||||
|
getClientAddress: () => '198.51.100.7'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgCreateBody = () =>
|
||||||
|
vi.mocked(axios.post).mock.calls.find(([url]) => String(url).endsWith('/api/org/'))?.[1];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(axios.post).mockReset();
|
||||||
|
vi.mocked(axios.post).mockResolvedValue({ data: { org: { id: 'org-1' } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the sign-in page', () => {
|
||||||
|
it('remembers the tags and the referring host of a first visit', async () => {
|
||||||
|
const cookies = jar();
|
||||||
|
await loadLogin(
|
||||||
|
'http://app.test/login?utm_source=site&utm_campaign=pricing',
|
||||||
|
{
|
||||||
|
referer: 'https://www.example.org/pricing'
|
||||||
|
},
|
||||||
|
cookies
|
||||||
|
);
|
||||||
|
expect(JSON.parse(cookies.store[SIGNUP_SOURCE_COOKIE])).toEqual({
|
||||||
|
utm_source: 'site',
|
||||||
|
utm_campaign: 'pricing',
|
||||||
|
referrer: 'www.example.org'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not take Google's redirect back for a source", async () => {
|
||||||
|
const cookies = jar();
|
||||||
|
await loadLogin(
|
||||||
|
'http://app.test/login?code=c&state=s',
|
||||||
|
{
|
||||||
|
referer: 'https://accounts.google.com/'
|
||||||
|
},
|
||||||
|
cookies
|
||||||
|
);
|
||||||
|
expect(cookies.store[SIGNUP_SOURCE_COOKIE]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('creating an org', () => {
|
||||||
|
it('sends the remembered source once, then forgets it', async () => {
|
||||||
|
const cookies = jar({
|
||||||
|
jwt_access: 'token',
|
||||||
|
[SIGNUP_SOURCE_COOKIE]: '{"utm_source":"site","referrer":"example.org"}'
|
||||||
|
});
|
||||||
|
await createOrg(cookies);
|
||||||
|
expect(orgCreateBody()).toEqual({
|
||||||
|
name: 'Acme',
|
||||||
|
signup_source: { utm_source: 'site', referrer: 'example.org' }
|
||||||
|
});
|
||||||
|
expect(cookies.store[SIGNUP_SOURCE_COOKIE]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no source when none was remembered', async () => {
|
||||||
|
await createOrg(jar({ jwt_access: 'token' }));
|
||||||
|
expect(orgCreateBody()).toEqual({ name: 'Acme' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the source when the create is refused', async () => {
|
||||||
|
vi.mocked(axios.post).mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error('Bad Request'), {
|
||||||
|
response: { status: 400, data: { errors: { name: ['taken'] } } }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const cookies = jar({ jwt_access: 'token', [SIGNUP_SOURCE_COOKIE]: '{"utm_source":"site"}' });
|
||||||
|
await createOrg(cookies);
|
||||||
|
expect(cookies.store[SIGNUP_SOURCE_COOKIE]).toBe('{"utm_source":"site"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
70
frontend/src/lib/server/signup-source.js
Normal file
70
frontend/src/lib/server/signup-source.js
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* Where a new org came from: the first visit's campaign tags and the host that
|
||||||
|
* referred it, kept in a cookie until the org is created, then sent with the
|
||||||
|
* create. The API cleans the value again; nothing here is a check.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SIGNUP_SOURCE_COOKIE = 'signup_source';
|
||||||
|
|
||||||
|
const TAGS = ['utm_source', 'utm_medium', 'utm_campaign'];
|
||||||
|
const MAX_LENGTH = 100;
|
||||||
|
const THIRTY_DAYS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tags on this URL, and the referring host unless it is this app's own.
|
||||||
|
* Only the host of a referrer is kept: its path or query can carry tokens.
|
||||||
|
* @param {URL} url
|
||||||
|
* @param {string | null} referer
|
||||||
|
* @returns {Record<string, string>}
|
||||||
|
*/
|
||||||
|
export function sourceOfVisit(url, referer) {
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const source = {};
|
||||||
|
for (const tag of TAGS) {
|
||||||
|
const value = url.searchParams.get(tag)?.trim();
|
||||||
|
if (value) source[tag] = value.slice(0, MAX_LENGTH);
|
||||||
|
}
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
const host = new URL(referer).hostname.toLowerCase();
|
||||||
|
if (host && host !== url.hostname.toLowerCase()) source.referrer = host;
|
||||||
|
} catch {
|
||||||
|
// A malformed header names no source.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember this visit's source unless an earlier visit already did: the first
|
||||||
|
* visit is the one that brought the person here.
|
||||||
|
* @param {{ url: URL, request: Request, cookies: import('@sveltejs/kit').Cookies }} event
|
||||||
|
* @param {boolean} secure
|
||||||
|
*/
|
||||||
|
export function rememberSignupSource({ url, request, cookies }, secure) {
|
||||||
|
if (cookies.get(SIGNUP_SOURCE_COOKIE) !== undefined) return;
|
||||||
|
const source = sourceOfVisit(url, request.headers.get('referer'));
|
||||||
|
cookies.set(SIGNUP_SOURCE_COOKIE, JSON.stringify(source), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure,
|
||||||
|
maxAge: THIRTY_DAYS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remembered source, or undefined when there is none or it will not parse.
|
||||||
|
* @param {{ get: (name: string) => string | undefined }} cookies
|
||||||
|
* @returns {Record<string, unknown> | undefined}
|
||||||
|
*/
|
||||||
|
export function readSignupSource(cookies) {
|
||||||
|
const raw = cookies.get(SIGNUP_SOURCE_COOKIE);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw);
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
frontend/src/lib/server/signup-source.test.js
Normal file
110
frontend/src/lib/server/signup-source.test.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
SIGNUP_SOURCE_COOKIE,
|
||||||
|
readSignupSource,
|
||||||
|
rememberSignupSource,
|
||||||
|
sourceOfVisit
|
||||||
|
} from './signup-source.js';
|
||||||
|
|
||||||
|
const APP = 'https://crm.example.com/login';
|
||||||
|
|
||||||
|
function fakeCookies(initial = {}) {
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const jar = { ...initial };
|
||||||
|
/** @type {any[]} */
|
||||||
|
const sets = [];
|
||||||
|
return {
|
||||||
|
jar,
|
||||||
|
sets,
|
||||||
|
get: (/** @type {string} */ name) => jar[name],
|
||||||
|
set: (/** @type {string} */ name, /** @type {string} */ value, /** @type {any} */ opts) => {
|
||||||
|
jar[name] = value;
|
||||||
|
sets.push({ name, value, opts });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} url @param {string | null} referer */
|
||||||
|
function visit(url, referer = null) {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (referer) headers.set('referer', referer);
|
||||||
|
return { url: new URL(url), request: new Request(url, { headers }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sourceOfVisit', () => {
|
||||||
|
it('keeps the campaign tags and the referring host', () => {
|
||||||
|
const url = new URL(`${APP}?utm_source=site&utm_medium=web&utm_campaign=pricing&x=1`);
|
||||||
|
expect(sourceOfVisit(url, 'https://News.Example.org/some/path?token=abc')).toEqual({
|
||||||
|
utm_source: 'site',
|
||||||
|
utm_medium: 'web',
|
||||||
|
utm_campaign: 'pricing',
|
||||||
|
referrer: 'news.example.org'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the app's own pages as a referrer", () => {
|
||||||
|
expect(sourceOfVisit(new URL(APP), 'https://crm.example.com/logout')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a malformed referrer and blank tags', () => {
|
||||||
|
expect(sourceOfVisit(new URL(`${APP}?utm_source=%20`), 'not a url')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cuts a long tag', () => {
|
||||||
|
const url = new URL(`${APP}?utm_campaign=${'c'.repeat(300)}`);
|
||||||
|
expect(sourceOfVisit(url, null).utm_campaign).toHaveLength(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rememberSignupSource', () => {
|
||||||
|
it('stores the first visit in an httpOnly cookie', () => {
|
||||||
|
const cookies = fakeCookies();
|
||||||
|
rememberSignupSource(
|
||||||
|
{
|
||||||
|
...visit(`${APP}?utm_source=site`, 'https://example.org/'),
|
||||||
|
cookies: /** @type {any} */ (cookies)
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(JSON.parse(cookies.jar[SIGNUP_SOURCE_COOKIE])).toEqual({
|
||||||
|
utm_source: 'site',
|
||||||
|
referrer: 'example.org'
|
||||||
|
});
|
||||||
|
expect(cookies.sets[0].opts).toMatchObject({
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: true,
|
||||||
|
path: '/'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a direct visit too, so a later one does not replace it', () => {
|
||||||
|
const cookies = fakeCookies();
|
||||||
|
rememberSignupSource({ ...visit(APP), cookies: /** @type {any} */ (cookies) }, false);
|
||||||
|
expect(cookies.jar[SIGNUP_SOURCE_COOKIE]).toBe('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an earlier visit', () => {
|
||||||
|
const cookies = fakeCookies({ [SIGNUP_SOURCE_COOKIE]: '{"referrer":"first.example"}' });
|
||||||
|
rememberSignupSource(
|
||||||
|
{ ...visit(`${APP}?utm_source=later`), cookies: /** @type {any} */ (cookies) },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(cookies.sets).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readSignupSource', () => {
|
||||||
|
it('returns the stored object', () => {
|
||||||
|
const cookies = fakeCookies({ [SIGNUP_SOURCE_COOKIE]: '{"utm_source":"site"}' });
|
||||||
|
expect(readSignupSource(cookies)).toEqual({ utm_source: 'site' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([undefined, '', 'not json', '[1,2]', '"text"', 'null'])(
|
||||||
|
'returns undefined for %j',
|
||||||
|
(raw) => {
|
||||||
|
const cookies = fakeCookies(raw === undefined ? {} : { [SIGNUP_SOURCE_COOKIE]: raw });
|
||||||
|
expect(readSignupSource(cookies)).toBeUndefined();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,7 @@ import * as publicEnv from '$app/env/public';
|
||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from '#lib/utils/pkce.js';
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from '#lib/utils/pkce.js';
|
||||||
import { describeError } from '#lib/server/log-safe.js';
|
import { describeError } from '#lib/server/log-safe.js';
|
||||||
import { relayHeaders } from '#lib/server/relay.js';
|
import { relayHeaders } from '#lib/server/relay.js';
|
||||||
|
import { rememberSignupSource } from '#lib/server/signup-source.js';
|
||||||
|
|
||||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||||
const GOOGLE_SCOPES = ['openid', 'email', 'profile'].join(' ');
|
const GOOGLE_SCOPES = ['openid', 'email', 'profile'].join(' ');
|
||||||
|
|
@ -62,6 +63,10 @@ export async function load({ url, cookies, getClientAddress, request }) {
|
||||||
return handleOAuthCallback(code, returnedState, cookies, { getClientAddress, request });
|
return handleOAuthCallback(code, returnedState, cookies, { getClientAddress, request });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the error and callback returns: Google's redirect back is not where
|
||||||
|
// the person came from.
|
||||||
|
rememberSignupSource({ url, request, cookies }, env.NODE_ENV === 'production');
|
||||||
|
|
||||||
// Check if user is already authenticated
|
// Check if user is already authenticated
|
||||||
const jwtAccess = cookies.get('jwt_access');
|
const jwtAccess = cookies.get('jwt_access');
|
||||||
if (jwtAccess) {
|
if (jwtAccess) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import axios from 'axios';
|
||||||
import { describeError } from '#lib/server/log-safe.js';
|
import { describeError } from '#lib/server/log-safe.js';
|
||||||
import { relayHeaders } from '#lib/server/relay.js';
|
import { relayHeaders } from '#lib/server/relay.js';
|
||||||
import { listPacks, applyPack } from '#lib/server/packs.js';
|
import { listPacks, applyPack } from '#lib/server/packs.js';
|
||||||
|
import { SIGNUP_SOURCE_COOKIE, readSignupSource } from '#lib/server/signup-source.js';
|
||||||
import { listTimezones } from '#lib/server/v2/organization.js';
|
import { listTimezones } from '#lib/server/v2/organization.js';
|
||||||
|
|
||||||
/** @type {import('./$types').PageServerLoad} */
|
/** @type {import('./$types').PageServerLoad} */
|
||||||
|
|
@ -79,9 +80,14 @@ export const actions = {
|
||||||
|
|
||||||
// Create organization and profile via Django API
|
// Create organization and profile via Django API
|
||||||
// Django's OrgProfileCreateView creates both org and profile
|
// Django's OrgProfileCreateView creates both org and profile
|
||||||
|
const signupSource = readSignupSource(cookies);
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${apiUrl}/api/org/`,
|
`${apiUrl}/api/org/`,
|
||||||
timezone ? { name: orgName.trim(), timezone } : { name: orgName.trim() },
|
{
|
||||||
|
name: orgName.trim(),
|
||||||
|
...(timezone ? { timezone } : {}),
|
||||||
|
...(signupSource ? { signup_source: signupSource } : {})
|
||||||
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${jwtAccess}`,
|
Authorization: `Bearer ${jwtAccess}`,
|
||||||
|
|
@ -93,6 +99,9 @@ export const actions = {
|
||||||
// Response should contain the created org
|
// Response should contain the created org
|
||||||
const newOrg = response.data.org || response.data;
|
const newOrg = response.data.org || response.data;
|
||||||
|
|
||||||
|
// Spent: a second org this person creates did not come from that visit.
|
||||||
|
cookies.delete(SIGNUP_SOURCE_COOKIE, { path: '/' });
|
||||||
|
|
||||||
// Set org cookie for the newly created org
|
// Set org cookie for the newly created org
|
||||||
await cookies.set('org', newOrg.id, {
|
await cookies.set('org', newOrg.id, {
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../config/api_config.dart';
|
import '../../config/api_config.dart';
|
||||||
|
import '../../data/models/org_settings.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
import '../../providers/settings_provider.dart';
|
||||||
import '../../routes/app_router.dart';
|
import '../../routes/app_router.dart';
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
import '../../widgets/forms/unsaved_changes.dart';
|
import '../../widgets/forms/unsaved_changes.dart';
|
||||||
|
|
@ -31,6 +33,9 @@ class _OrgCreateScreenState extends ConsumerState<OrgCreateScreen> {
|
||||||
/// different spelling of the same zone.
|
/// different spelling of the same zone.
|
||||||
List<_Zone> _zones = const [];
|
List<_Zone> _zones = const [];
|
||||||
String _timezone = 'UTC';
|
String _timezone = 'UTC';
|
||||||
|
|
||||||
|
/// The chosen pack id; empty is "Skip for now".
|
||||||
|
String _vertical = '';
|
||||||
bool _loadingZones = true;
|
bool _loadingZones = true;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
|
|
||||||
|
|
@ -93,26 +98,61 @@ class _OrgCreateScreenState extends ConsumerState<OrgCreateScreen> {
|
||||||
.createOrganization(_nameController.text.trim(), timezone: _timezone);
|
.createOrganization(_nameController.text.trim(), timezone: _timezone);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _submitting = false);
|
|
||||||
|
|
||||||
if (failure != null) {
|
if (failure != null) {
|
||||||
|
setState(() => _submitting = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(failure), backgroundColor: Colors.red),
|
SnackBar(content: Text(failure), backgroundColor: Colors.red),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The org already exists and is selected, so a pack that will not apply
|
||||||
|
// must not undo it, as on the web. Unlike the web, say so: Settings >
|
||||||
|
// Organization can apply it again.
|
||||||
|
String? packFailure;
|
||||||
|
if (_vertical.isNotEmpty) {
|
||||||
|
final result = await ref
|
||||||
|
.read(orgSettingsProvider.notifier)
|
||||||
|
.applyPack(_vertical);
|
||||||
|
packFailure = result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _submitting = false);
|
||||||
|
if (packFailure != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Your organization is ready, but the industry setup did not '
|
||||||
|
'apply ($packFailure). You can apply it again from Organization '
|
||||||
|
'settings.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
// A new workspace is empty, so the first thing offered is bringing in
|
// A new workspace is empty, so the first thing offered is bringing in
|
||||||
// the people from a spreadsheet, with a skip to the dashboard. The web's
|
// the people from a spreadsheet, with a skip to the dashboard. The web's
|
||||||
// org/new goes to /welcome the same way.
|
// org/new goes to /welcome the same way.
|
||||||
context.go(AppRoutes.importWelcome);
|
context.go(AppRoutes.importWelcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _packOption(String id, String name, String description) =>
|
||||||
|
RadioListTile<String>(
|
||||||
|
value: id,
|
||||||
|
enabled: !_submitting,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(name, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: description.isEmpty ? null : Text(description),
|
||||||
|
);
|
||||||
|
|
||||||
/// The timezone is a guess the screen made, not something the user typed,
|
/// The timezone is a guess the screen made, not something the user typed,
|
||||||
/// so only a name counts as work worth keeping.
|
/// so only a name counts as work worth keeping.
|
||||||
bool get _hasUnsavedChanges => _nameController.text.trim().isNotEmpty;
|
bool get _hasUnsavedChanges => _nameController.text.trim().isNotEmpty;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// A list that fails to load hides the question, as the web does.
|
||||||
|
final packs = ref.watch(orgPacksProvider).value ?? const <VerticalPack>[];
|
||||||
return UnsavedChangesGuard(
|
return UnsavedChangesGuard(
|
||||||
hasUnsavedChanges: () => _hasUnsavedChanges,
|
hasUnsavedChanges: () => _hasUnsavedChanges,
|
||||||
isSaving: _submitting,
|
isSaving: _submitting,
|
||||||
|
|
@ -195,6 +235,39 @@ class _OrgCreateScreenState extends ConsumerState<OrgCreateScreen> {
|
||||||
'Settings.',
|
'Settings.',
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
|
if (packs.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'What kind of business is this?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Sets up a starter pipeline, tags and fields for your '
|
||||||
|
'industry. You can change everything later.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
RadioGroup<String>(
|
||||||
|
groupValue: _vertical,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) setState(() => _vertical = value);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_packOption(
|
||||||
|
'',
|
||||||
|
'Skip for now',
|
||||||
|
'Start with a blank workspace.',
|
||||||
|
),
|
||||||
|
for (final pack in packs)
|
||||||
|
_packOption(pack.id, pack.name, pack.description),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ description: "CRM for AI and People."
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
version: 1.11.0+15
|
version: 1.12.0+16
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:bottle_crm/data/models/auth_response.dart';
|
import 'package:bottle_crm/data/models/auth_response.dart';
|
||||||
|
import 'package:bottle_crm/data/models/org_settings.dart';
|
||||||
import 'package:bottle_crm/providers/auth_provider.dart';
|
import 'package:bottle_crm/providers/auth_provider.dart';
|
||||||
|
import 'package:bottle_crm/providers/settings_provider.dart';
|
||||||
import 'package:bottle_crm/routes/app_router.dart';
|
import 'package:bottle_crm/routes/app_router.dart';
|
||||||
import 'package:bottle_crm/screens/auth/org_create_screen.dart';
|
import 'package:bottle_crm/screens/auth/org_create_screen.dart';
|
||||||
import 'package:bottle_crm/screens/auth/org_selection_screen.dart';
|
import 'package:bottle_crm/screens/auth/org_selection_screen.dart';
|
||||||
|
|
@ -149,14 +151,165 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('the industry choice', () {
|
||||||
|
testWidgets('offers the packs the server lists, and a way to skip', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(_creating(org: _FakeOrgSettings()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('What kind of business is this?'), findsOneWidget);
|
||||||
|
expect(find.text('Skip for now'), findsOneWidget);
|
||||||
|
expect(find.text('Professional services'), findsOneWidget);
|
||||||
|
expect(find.text('Real estate'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('is left out when the pack list is empty', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_creating(org: _FakeOrgSettings(), packs: () async => const []),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('What kind of business is this?'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('is left out when the pack list fails to load', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_creating(
|
||||||
|
org: _FakeOrgSettings(),
|
||||||
|
packs: () async => throw Exception('offline'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('What kind of business is this?'), findsNothing);
|
||||||
|
// The form itself still works without it.
|
||||||
|
expect(find.text('Create organization'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the chosen pack is applied to the new organization', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final org = _FakeOrgSettings();
|
||||||
|
await tester.pumpWidget(_creating(org: org));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _create(tester, pick: 'Real estate');
|
||||||
|
|
||||||
|
expect(org.applied, ['real-estate']);
|
||||||
|
expect(find.text('the welcome screen'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('skipping applies nothing', (tester) async {
|
||||||
|
final org = _FakeOrgSettings();
|
||||||
|
await tester.pumpWidget(_creating(org: org));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _create(tester);
|
||||||
|
|
||||||
|
expect(org.applied, isEmpty);
|
||||||
|
expect(find.text('the welcome screen'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a pack that will not apply still leaves the organization '
|
||||||
|
'created, and says so', (tester) async {
|
||||||
|
final org = _FakeOrgSettings(error: 'Only admins can apply a pack.');
|
||||||
|
await tester.pumpWidget(_creating(org: org));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _create(tester, pick: 'Professional services');
|
||||||
|
|
||||||
|
expect(org.applied, ['professional-services']);
|
||||||
|
expect(find.text('the welcome screen'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.textContaining('the industry setup did not apply'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a create the server refuses applies no pack', (tester) async {
|
||||||
|
final org = _FakeOrgSettings();
|
||||||
|
await tester.pumpWidget(_creating(org: org, createFails: true));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await _create(tester, pick: 'Real estate');
|
||||||
|
|
||||||
|
expect(org.applied, isEmpty);
|
||||||
|
expect(find.text('That name is taken.'), findsOneWidget);
|
||||||
|
expect(find.text('the welcome screen'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _host(Widget screen, {List<Organization> orgs = const []}) =>
|
Widget _host(Widget screen, {List<Organization> orgs = const []}) =>
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: [authProvider.overrideWith(() => _FakeAuth(orgs))],
|
overrides: [
|
||||||
|
authProvider.overrideWith(() => _FakeAuth(orgs)),
|
||||||
|
orgPacksProvider.overrideWith((ref) async => const <VerticalPack>[]),
|
||||||
|
],
|
||||||
child: MaterialApp(home: screen),
|
child: MaterialApp(home: screen),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const _packs = [
|
||||||
|
VerticalPack(
|
||||||
|
id: 'professional-services',
|
||||||
|
name: 'Professional services',
|
||||||
|
description: 'Agencies, consultancies and accounting firms.',
|
||||||
|
),
|
||||||
|
VerticalPack(id: 'real-estate', name: 'Real estate'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The create screen under a router, so a finished create lands somewhere
|
||||||
|
/// assertable. [org] records which packs were applied.
|
||||||
|
Widget _creating({
|
||||||
|
required _FakeOrgSettings org,
|
||||||
|
Future<List<VerticalPack>> Function()? packs,
|
||||||
|
bool createFails = false,
|
||||||
|
}) {
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: AppRoutes.orgCreate,
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.orgCreate,
|
||||||
|
builder: (_, _) => const OrgCreateScreen(),
|
||||||
|
),
|
||||||
|
// A Scaffold like the real welcome screen's, so a snackbar raised on
|
||||||
|
// the way there has somewhere to show.
|
||||||
|
GoRoute(
|
||||||
|
path: AppRoutes.importWelcome,
|
||||||
|
builder: (_, _) => const Scaffold(body: Text('the welcome screen')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
authProvider.overrideWith(
|
||||||
|
createFails ? _RefusingAuth.new : _CreatingAuth.new,
|
||||||
|
),
|
||||||
|
orgPacksProvider.overrideWith(
|
||||||
|
(ref) => packs == null ? Future.value(_packs) : packs(),
|
||||||
|
),
|
||||||
|
orgSettingsProvider.overrideWith(() => org),
|
||||||
|
],
|
||||||
|
child: MaterialApp.router(routerConfig: router),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _create(WidgetTester tester, {String? pick}) async {
|
||||||
|
await tester.enterText(find.byType(TextFormField), 'Acme Inc.');
|
||||||
|
if (pick != null) {
|
||||||
|
await tester.ensureVisible(find.text(pick));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text(pick));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
await tester.ensureVisible(find.text('Create organization'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Create organization'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
class _FakeAuth extends AuthNotifier {
|
class _FakeAuth extends AuthNotifier {
|
||||||
_FakeAuth(this.orgs);
|
_FakeAuth(this.orgs);
|
||||||
|
|
||||||
|
|
@ -180,3 +333,32 @@ class _CreatingAuth extends _FakeAuth {
|
||||||
Future<String?> createOrganization(String name, {String? timezone}) async =>
|
Future<String?> createOrganization(String name, {String? timezone}) async =>
|
||||||
null;
|
null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _RefusingAuth extends _FakeAuth {
|
||||||
|
_RefusingAuth() : super(const []);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> createOrganization(String name, {String? timezone}) async =>
|
||||||
|
'That name is taken.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records each pack applied. [error] makes every apply fail with it.
|
||||||
|
class _FakeOrgSettings extends OrgSettingsNotifier {
|
||||||
|
_FakeOrgSettings({this.error});
|
||||||
|
|
||||||
|
final String? error;
|
||||||
|
final applied = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<OrgSettings> build() async =>
|
||||||
|
OrgSettings.fromJson(const {'id': 'org-1', 'name': 'Acme Inc.'});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<({String? error, PackApplyReport? report})> applyPack(
|
||||||
|
String packId,
|
||||||
|
) async {
|
||||||
|
applied.add(packId);
|
||||||
|
if (error != null) return (error: error, report: null);
|
||||||
|
return (error: null, report: const PackApplyReport(created: 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ import 'package:bottle_crm/screens/settings/macros_screen.dart';
|
||||||
import 'package:bottle_crm/screens/settings/mailbox_form_sheet.dart';
|
import 'package:bottle_crm/screens/settings/mailbox_form_sheet.dart';
|
||||||
import 'package:bottle_crm/screens/settings/organization_edit_screen.dart';
|
import 'package:bottle_crm/screens/settings/organization_edit_screen.dart';
|
||||||
import 'package:bottle_crm/screens/settings/organization_screen.dart';
|
import 'package:bottle_crm/screens/settings/organization_screen.dart';
|
||||||
|
import 'package:bottle_crm/screens/auth/org_create_screen.dart';
|
||||||
import 'package:bottle_crm/screens/tickets/close_with_children_dialog.dart';
|
import 'package:bottle_crm/screens/tickets/close_with_children_dialog.dart';
|
||||||
import 'package:bottle_crm/screens/tickets/macro_picker_sheet.dart';
|
import 'package:bottle_crm/screens/tickets/macro_picker_sheet.dart';
|
||||||
import 'package:bottle_crm/screens/settings/settings_hub_screen.dart';
|
import 'package:bottle_crm/screens/settings/settings_hub_screen.dart';
|
||||||
|
|
@ -3011,6 +3012,58 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('new organization at 390px', () {
|
||||||
|
// Every pack listed, the longest description included, so the radio rows
|
||||||
|
// are at their tallest.
|
||||||
|
Widget orgCreateApp() => ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
orgPacksProvider.overrideWith(
|
||||||
|
(ref) async => const [
|
||||||
|
VerticalPack(
|
||||||
|
id: 'professional-services',
|
||||||
|
name: 'Professional services',
|
||||||
|
description:
|
||||||
|
'Agencies, consultancies and accounting firms selling '
|
||||||
|
'billable work.',
|
||||||
|
),
|
||||||
|
VerticalPack(
|
||||||
|
id: 'education-admissions',
|
||||||
|
name: 'Education admissions',
|
||||||
|
description:
|
||||||
|
'Coaching centres, training institutes and colleges '
|
||||||
|
'managing admissions.',
|
||||||
|
),
|
||||||
|
VerticalPack(
|
||||||
|
id: 'events-weddings',
|
||||||
|
name: 'Events & weddings',
|
||||||
|
description:
|
||||||
|
'Wedding planners, event agencies and venues taking '
|
||||||
|
'enquiries through to a delivered event.',
|
||||||
|
),
|
||||||
|
VerticalPack(
|
||||||
|
id: 'real-estate',
|
||||||
|
name: 'Real estate',
|
||||||
|
description:
|
||||||
|
'Brokers, agencies and developers selling residential and '
|
||||||
|
'commercial property.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: OrgCreateScreen()),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final scale in [1.0, 1.5]) {
|
||||||
|
testWidgets('the industry choice fits at text scale $scale', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pump(tester, orgCreateApp(), textScale: scale);
|
||||||
|
expect(find.text('What kind of business is this?'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
group('organization at 390px', () {
|
group('organization at 390px', () {
|
||||||
testWidgets('renders without overflowing', (tester) async {
|
testWidgets('renders without overflowing', (tester) async {
|
||||||
await pump(tester, organizationApp(isAdmin: true));
|
await pump(tester, organizationApp(isAdmin: true));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue