Skip to content

Commit 200fe25

Browse files
committed
feat(crm): flag expired dedicated services
Dedicated servers can remain active long after their contracts expire without an actionable CRM signal. Add a stop-server follow-up after the 60-day grace period and clear it when service is renewed.
1 parent 6c5b157 commit 200fe25

6 files changed

Lines changed: 217 additions & 0 deletions

File tree

weblate_web/crm/tests.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,29 @@ def test_work_queue_shows_over_limit_followup(self):
650650
)
651651
self.assertContains(response, customer.get_absolute_url())
652652

653+
@override_settings(
654+
CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
655+
)
656+
def test_work_queue_shows_expired_dedicated_followup(self):
657+
customer = self.create_customer("EXPIRED DEDICATED CUSTOMER")
658+
service = Service.objects.create(customer=customer)
659+
CustomerFollowUp.objects.create(
660+
customer=customer,
661+
service=service,
662+
follow_up_at=timezone.now(),
663+
type=CustomerFollowUp.Type.EXPIRED_DEDICATED,
664+
)
665+
666+
response = self.client.get(reverse("crm:work-queue"))
667+
668+
self.assertContains(response, "Stop server")
669+
self.assertContains(
670+
response,
671+
"Stop the dedicated instance because its support contract expired over "
672+
"two months ago.",
673+
)
674+
self.assertContains(response, customer.get_absolute_url())
675+
653676
def test_work_queue_suggests_unpaid_old_invoices_only(self):
654677
old_invoice = self.create_queue_invoice("OLD INVOICE CUSTOMER", age_days=8)
655678
fresh_invoice = self.create_queue_invoice("FRESH INVOICE CUSTOMER", age_days=6)

weblate_web/crm/workqueue.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ def get_followup_label(followup: CustomerFollowUp, *, due: bool) -> str:
201201
return _("Locked URL")
202202
case CustomerFollowUp.Type.OVER_LIMIT:
203203
return _("Over limits")
204+
case CustomerFollowUp.Type.EXPIRED_DEDICATED:
205+
return _("Stop server")
204206
return str(followup.get_type_display())
205207

206208

weblate_web/models.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
"languages",
8888
)
8989

90+
DEDICATED_CONTRACT_GRACE_PERIOD = timedelta(days=60)
91+
9092

9193
ALLOWED_IMAGES = {"image/jpeg", "image/png"}
9294
PILLOW_VALIDATION_ERRORS = (
@@ -1840,6 +1842,8 @@ def save( # type: ignore[override]
18401842
self.create_locked_site_url_followup()
18411843
return
18421844

1845+
self.reconcile_expired_dedicated_followup()
1846+
18431847
if self.service.status == "hosted" and (
18441848
exceeded_limits := self.service.get_exceeded_limits(self)
18451849
):
@@ -1907,6 +1911,35 @@ def create_over_limit_followup(
19071911
},
19081912
)
19091913

1914+
def reconcile_expired_dedicated_followup(self) -> None:
1915+
latest_subscription = self.service.hosted_subscriptions.first()
1916+
if latest_subscription is None:
1917+
return
1918+
1919+
current_time = timezone.now()
1920+
if latest_subscription.expires > current_time:
1921+
self.service.followups.filter(
1922+
type=CustomerFollowUp.Type.EXPIRED_DEDICATED
1923+
).delete()
1924+
return
1925+
if latest_subscription.expires > current_time - DEDICATED_CONTRACT_GRACE_PERIOD:
1926+
return
1927+
1928+
CustomerFollowUp.objects.update_or_create(
1929+
service=self.service,
1930+
type=CustomerFollowUp.Type.EXPIRED_DEDICATED,
1931+
defaults={
1932+
"customer": self.service.customer,
1933+
"follow_up_at": current_time,
1934+
"details": {
1935+
"service_id": self.service_id,
1936+
"report_id": self.pk,
1937+
"site_url": self.site_url,
1938+
"contract_expired_at": latest_subscription.expires.isoformat(),
1939+
},
1940+
},
1941+
)
1942+
19101943

19111944
class Project(models.Model):
19121945
service = models.ForeignKey(Service, on_delete=models.deletion.CASCADE)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright © Michal Čihař <michal@weblate.org>
2+
#
3+
# This file is part of Weblate <https://weblate.org/>
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
18+
from __future__ import annotations
19+
20+
from django.db import migrations, models
21+
22+
23+
class Migration(migrations.Migration):
24+
dependencies = [("payments", "0005_customerfollowup_over_limit")]
25+
26+
operations = [
27+
migrations.AlterField(
28+
model_name="customerfollowup",
29+
name="type",
30+
field=models.PositiveSmallIntegerField(
31+
choices=[
32+
(1, "Manual"),
33+
(2, "Duplicate payment"),
34+
(3, "Locked site URL"),
35+
(4, "Service over limits"),
36+
(5, "Expired dedicated service"),
37+
],
38+
db_index=True,
39+
default=1,
40+
verbose_name="Follow-up type",
41+
),
42+
),
43+
migrations.AddConstraint(
44+
model_name="customerfollowup",
45+
constraint=models.UniqueConstraint(
46+
condition=models.Q(("service__isnull", False), ("type", 5)),
47+
fields=("service", "type"),
48+
name="unique_expired_dedicated_followup_per_service",
49+
),
50+
),
51+
]

weblate_web/payments/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,7 @@ class Type(models.IntegerChoices):
698698
DUPLICATE_PAYMENT = 2, gettext_lazy("Duplicate payment")
699699
LOCKED_SITE_URL = 3, gettext_lazy("Locked site URL")
700700
OVER_LIMIT = 4, gettext_lazy("Service over limits")
701+
EXPIRED_DEDICATED = 5, gettext_lazy("Expired dedicated service")
701702

702703
customer = models.ForeignKey(
703704
Customer, related_name="followups", on_delete=models.deletion.CASCADE
@@ -745,6 +746,11 @@ class Meta:
745746
condition=models.Q(service__isnull=False, type=4),
746747
name="unique_over_limit_followup_per_service",
747748
),
749+
models.UniqueConstraint(
750+
fields=("service", "type"),
751+
condition=models.Q(service__isnull=False, type=5),
752+
name="unique_expired_dedicated_followup_per_service",
753+
),
748754
]
749755

750756
def __str__(self) -> str:
@@ -756,6 +762,11 @@ def display_note(self) -> str:
756762
return self.note
757763
if self.type == self.Type.OVER_LIMIT:
758764
return gettext("Review usage and upgrade the dedicated instance.")
765+
if self.type == self.Type.EXPIRED_DEDICATED:
766+
return gettext(
767+
"Stop the dedicated instance because its support contract expired "
768+
"over two months ago."
769+
)
759770
return ""
760771

761772

weblate_web/tests.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3232,6 +3232,103 @@ def test_shared_report_does_not_create_over_limit_followup(self) -> None:
32323232
service.followups.filter(type=CustomerFollowUp.Type.OVER_LIMIT).exists()
32333233
)
32343234

3235+
def test_expired_dedicated_report_creates_stop_followup(self) -> None:
3236+
Package.objects.create(name="community", verbose="Community support", price=0)
3237+
package = Package.objects.create(
3238+
name="dedicated:expired",
3239+
verbose="Expired dedicated",
3240+
price=100,
3241+
category=PackageCategory.PACKAGE_DEDICATED,
3242+
)
3243+
customer = Customer.objects.create(user_id=-1, origin=PAYMENTS_ORIGIN)
3244+
service = Service.objects.create(
3245+
customer=customer, backup_repository="already-configured"
3246+
)
3247+
subscription = service.subscription_set.create(
3248+
package=package,
3249+
expires=timezone.now() - timedelta(days=61),
3250+
)
3251+
3252+
self._post_support_report(service, "https://expired.example.com")
3253+
3254+
followup = service.followups.get(type=CustomerFollowUp.Type.EXPIRED_DEDICATED)
3255+
first_followup_id = followup.pk
3256+
first_report_id = followup.details["report_id"]
3257+
self.assertEqual(followup.customer, customer)
3258+
self.assertEqual(followup.details["service_id"], service.pk)
3259+
self.assertEqual(
3260+
followup.details["contract_expired_at"], subscription.expires.isoformat()
3261+
)
3262+
self.assertEqual(followup.details["site_url"], "https://expired.example.com")
3263+
3264+
self._post_support_report(service, "https://expired.example.com")
3265+
3266+
followup.refresh_from_db()
3267+
self.assertEqual(followup.pk, first_followup_id)
3268+
self.assertNotEqual(followup.details["report_id"], first_report_id)
3269+
self.assertEqual(
3270+
service.followups.filter(
3271+
type=CustomerFollowUp.Type.EXPIRED_DEDICATED
3272+
).count(),
3273+
1,
3274+
)
3275+
3276+
subscription.expires = timezone.now() + timedelta(days=30)
3277+
subscription.save(update_fields=["expires"])
3278+
self._post_support_report(service, "https://expired.example.com")
3279+
3280+
self.assertFalse(
3281+
service.followups.filter(
3282+
type=CustomerFollowUp.Type.EXPIRED_DEDICATED
3283+
).exists()
3284+
)
3285+
3286+
def test_dedicated_report_waits_for_contract_grace_period(self) -> None:
3287+
Package.objects.create(name="community", verbose="Community support", price=0)
3288+
package = Package.objects.create(
3289+
name="dedicated:grace",
3290+
verbose="Dedicated in grace period",
3291+
price=100,
3292+
category=PackageCategory.PACKAGE_DEDICATED,
3293+
)
3294+
customer = Customer.objects.create(user_id=-1, origin=PAYMENTS_ORIGIN)
3295+
service = Service.objects.create(customer=customer)
3296+
service.subscription_set.create(
3297+
package=package,
3298+
expires=timezone.now() - timedelta(days=59),
3299+
)
3300+
3301+
self._post_support_report(service, "https://grace.example.com")
3302+
3303+
self.assertFalse(
3304+
service.followups.filter(
3305+
type=CustomerFollowUp.Type.EXPIRED_DEDICATED
3306+
).exists()
3307+
)
3308+
3309+
def test_non_dedicated_report_does_not_create_stop_followup(self) -> None:
3310+
Package.objects.create(name="community", verbose="Community support", price=0)
3311+
package = Package.objects.create(
3312+
name="shared:expired",
3313+
verbose="Expired shared hosting",
3314+
price=100,
3315+
category=PackageCategory.PACKAGE_SHARED,
3316+
)
3317+
customer = Customer.objects.create(user_id=-1, origin=PAYMENTS_ORIGIN)
3318+
service = Service.objects.create(customer=customer)
3319+
service.subscription_set.create(
3320+
package=package,
3321+
expires=timezone.now() - timedelta(days=61),
3322+
)
3323+
3324+
self._post_support_report(service, "https://shared-expired.example.com")
3325+
3326+
self.assertFalse(
3327+
service.followups.filter(
3328+
type=CustomerFollowUp.Type.EXPIRED_DEDICATED
3329+
).exists()
3330+
)
3331+
32353332
def test_support_rejects_invalid_report_payload(self) -> None:
32363333
service = self.perform_support()
32373334
report_count = service.report_set.count()

0 commit comments

Comments
 (0)