Skip to content

Commit c970c11

Browse files
committed
feat: track maintenance windows for extended support
1 parent 4ca3ab3 commit c970c11

10 files changed

Lines changed: 293 additions & 1 deletion

File tree

weblate_web/admin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class ServiceAdmin(admin.ModelAdmin):
6464
"site_title",
6565
"site_url",
6666
"note",
67+
"maintenance_window",
6768
"projects_limit",
6869
"languages_limit",
6970
"source_strings_limit",
@@ -84,6 +85,7 @@ class ServiceAdmin(admin.ModelAdmin):
8485
"customer__name",
8586
"site_url",
8687
"note",
88+
"maintenance_window",
8789
)
8890
date_hierarchy = "created"
8991
autocomplete_fields = ("customer",)

weblate_web/crm/forms.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from django import forms
2323
from django.utils.translation import gettext_lazy
2424

25+
from weblate_web.models import Service
26+
2527

2628
class RefundConfirmationForm(forms.Form):
2729
description = forms.CharField(
@@ -31,3 +33,9 @@ class RefundConfirmationForm(forms.Form):
3133
help_text=gettext_lazy("Optional note describing how the refund was done."),
3234
widget=forms.TextInput(),
3335
)
36+
37+
38+
class ServiceMaintenanceWindowForm(forms.ModelForm):
39+
class Meta:
40+
model = Service
41+
fields = ("maintenance_window",)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#
2+
# Copyright © Michal Čihař <michal@weblate.org>
3+
#
4+
# This file is part of Weblate <https://weblate.org/>
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
from __future__ import annotations
21+
22+
from django.db import migrations, models
23+
24+
25+
class Migration(migrations.Migration):
26+
dependencies = [
27+
("CRM", "0007_alter_interaction_origin"),
28+
]
29+
30+
operations = [
31+
migrations.AlterField(
32+
model_name="interaction",
33+
name="origin",
34+
field=models.IntegerField(
35+
choices=[
36+
(1, "Outbound e-mail"),
37+
(2, "Merged customer"),
38+
(3, "Attachment exchanged in Zammad"),
39+
(4, "VIES validation"),
40+
(5, "Manual payment"),
41+
(6, "Maintenance window"),
42+
],
43+
verbose_name="Origin",
44+
),
45+
),
46+
]

weblate_web/crm/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Origin(models.IntegerChoices):
1616
ZAMMAD_ATTACHMENT = 3, "Attachment exchanged in Zammad"
1717
VIES = 4, "VIES validation"
1818
MANUAL_PAYMENT = 5, "Manual payment"
19+
MAINTENANCE_WINDOW = 6, "Maintenance window"
1920

2021
timestamp = models.DateTimeField(default=timezone.now, verbose_name="Timestamp")
2122
origin = models.IntegerField(choices=Origin, verbose_name="Origin")

weblate_web/crm/templates/weblate_web/service_detail.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ <h2>{{ object.site_title }}</h2>
3737
{% endif %}
3838
<a href="{% url "admin:weblate_web_service_change" object_id=object.pk %}">Edit</a>
3939
</p>
40+
{% if maintenance_window_form %}
41+
<h2>{% translate "Maintenance window" %}</h2>
42+
<form method="post">
43+
{% csrf_token %}
44+
{{ maintenance_window_form }}
45+
<input type="submit"
46+
name="update_maintenance_window"
47+
value="{% translate "Update maintenance window" %}">
48+
</form>
49+
{% endif %}
4050
{% for subscription in object.subscription_set.all %}
4151
<h2>{{ subscription.package }}</h2>
4252
<p>

weblate_web/crm/templates/weblate_web/service_list_content.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
</td>
1111
<td>{{ object.site_version }}</td>
1212
<td>{{ object.get_status_display }}</td>
13+
{% if kind == "extended" %}<td>{{ object.maintenance_window|default:"-" }}</td>{% endif %}
1314
<td>
1415
{% for subscription in object.enabled_subscriptions %}
1516
{{ subscription.package }}

weblate_web/crm/tests.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ def setUp(self):
5555
)
5656
self.client.force_login(self.user)
5757

58+
def create_extended_service(
59+
self,
60+
*,
61+
enabled: bool = True,
62+
expires=None,
63+
maintenance_window: str = "",
64+
) -> Service:
65+
Package.objects.get_or_create(name="community", defaults={"price": 0})
66+
package, _ = Package.objects.get_or_create(
67+
name="extended",
68+
defaults={
69+
"verbose": "Extended support",
70+
"price": 600,
71+
"category": PackageCategory.PACKAGE_SUPPORT,
72+
},
73+
)
74+
customer = self.create_customer()
75+
payment = Payment.objects.create(customer=customer, amount=1)
76+
service = Service.objects.create(
77+
customer=customer, maintenance_window=maintenance_window
78+
)
79+
service.subscription_set.create(
80+
package=package,
81+
expires=expires or timezone.now() + timedelta(days=30),
82+
enabled=enabled,
83+
payment=payment.pk,
84+
)
85+
return service
86+
5887
def test_customer_merge(self):
5988
customer1 = self.create_customer("TEST CUSTOMER 1")
6089
customer2 = self.create_customer("TEST CUSTOMER 2")
@@ -189,6 +218,116 @@ def test_service(self):
189218
subscription1.refresh_from_db()
190219
self.assertFalse(subscription1.enabled)
191220

221+
def test_service_maintenance_window(self):
222+
service = self.create_extended_service()
223+
224+
response = self.client.get(service.get_absolute_url())
225+
self.assertContains(response, 'name="maintenance_window"')
226+
self.assertContains(response, "Update maintenance window")
227+
228+
response = self.client.post(
229+
service.get_absolute_url(),
230+
{
231+
"update_maintenance_window": 1,
232+
"maintenance_window": "Sundays 02:00 UTC",
233+
},
234+
)
235+
self.assertRedirects(response, service.get_absolute_url())
236+
237+
service.refresh_from_db()
238+
self.assertEqual(service.maintenance_window, "Sundays 02:00 UTC")
239+
240+
interaction = Interaction.objects.get(customer=service.customer)
241+
self.assertEqual(interaction.origin, Interaction.Origin.MAINTENANCE_WINDOW)
242+
self.assertEqual(interaction.user, self.user)
243+
self.assertEqual(
244+
interaction.summary, f"Maintenance window updated for service {service.pk}"
245+
)
246+
self.assertEqual(interaction.content, "Maintenance window: Sundays 02:00 UTC")
247+
248+
response = self.client.post(
249+
service.get_absolute_url(),
250+
{
251+
"update_maintenance_window": 1,
252+
"maintenance_window": "Sundays 02:00 UTC",
253+
},
254+
)
255+
self.assertRedirects(response, service.get_absolute_url())
256+
self.assertEqual(
257+
Interaction.objects.filter(customer=service.customer).count(), 1
258+
)
259+
260+
def test_service_maintenance_window_requires_active_extended_support(self):
261+
Package.objects.create(name="community", price=0)
262+
basic = Package.objects.create(
263+
name="basic",
264+
verbose="Basic support",
265+
price=300,
266+
category=PackageCategory.PACKAGE_SUPPORT,
267+
)
268+
customer = self.create_customer()
269+
payment = Payment.objects.create(customer=customer, amount=1)
270+
basic_service = Service.objects.create(customer=customer)
271+
basic_service.subscription_set.create(
272+
package=basic,
273+
expires=timezone.now() + timedelta(days=30),
274+
payment=payment.pk,
275+
)
276+
expired_service = self.create_extended_service(
277+
expires=timezone.now() - timedelta(days=1)
278+
)
279+
disabled_service = self.create_extended_service(enabled=False)
280+
281+
for service in (basic_service, expired_service, disabled_service):
282+
response = self.client.get(service.get_absolute_url())
283+
self.assertNotContains(response, "Update maintenance window")
284+
285+
response = self.client.post(
286+
service.get_absolute_url(),
287+
{
288+
"update_maintenance_window": 1,
289+
"maintenance_window": "Sundays 02:00 UTC",
290+
},
291+
)
292+
self.assertEqual(response.status_code, 403)
293+
service.refresh_from_db()
294+
self.assertEqual(service.maintenance_window, "")
295+
296+
self.assertFalse(Interaction.objects.exists())
297+
298+
def test_service_maintenance_window_requires_change_service_permission(self):
299+
service = self.create_extended_service()
300+
readonly_user = User.objects.create_user(
301+
username="readonly", email="readonly@example.com", is_staff=True
302+
)
303+
self.client.force_login(readonly_user)
304+
305+
response = self.client.get(service.get_absolute_url())
306+
self.assertEqual(response.status_code, 403)
307+
308+
response = self.client.post(
309+
service.get_absolute_url(),
310+
{
311+
"update_maintenance_window": 1,
312+
"maintenance_window": "Sundays 02:00 UTC",
313+
},
314+
)
315+
self.assertEqual(response.status_code, 403)
316+
317+
service.refresh_from_db()
318+
self.assertEqual(service.maintenance_window, "")
319+
self.assertFalse(Interaction.objects.exists())
320+
321+
def test_extended_service_list_shows_maintenance_window(self):
322+
self.create_extended_service(maintenance_window="Sundays 02:00 UTC")
323+
324+
response = self.client.get(
325+
reverse("crm:service-list", kwargs={"kind": "extended"})
326+
)
327+
328+
self.assertContains(response, "Extended support services")
329+
self.assertContains(response, "Sundays 02:00 UTC")
330+
192331
@responses.activate
193332
def test_customer_quote(self):
194333
cnb_mock_rates()

weblate_web/crm/views.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434
from django.utils.translation import override
3535
from django.views.generic import DetailView, ListView, TemplateView
3636

37-
from weblate_web.crm.forms import RefundConfirmationForm
37+
from weblate_web.crm.forms import (
38+
RefundConfirmationForm,
39+
ServiceMaintenanceWindowForm,
40+
)
3841
from weblate_web.forms import NewSubscriptionForm
3942
from weblate_web.invoices.forms import CustomerReferenceForm
4043
from weblate_web.invoices.models import (
@@ -164,10 +167,40 @@ class ServiceDetailView(CRMMixin, DetailView[Service]): # type: ignore[misc]
164167
def get_context_data(self, **kwargs):
165168
context = super().get_context_data(**kwargs)
166169
context["reference_form"] = CustomerReferenceForm()
170+
if self.object.has_active_extended_support:
171+
context["maintenance_window_form"] = ServiceMaintenanceWindowForm(
172+
instance=self.object
173+
)
167174
return context
168175

169176
def post(self, request, *args, **kwargs):
170177
service = self.get_object()
178+
if "update_maintenance_window" in request.POST:
179+
if not service.has_active_extended_support:
180+
raise PermissionDenied
181+
maintenance_window = service.maintenance_window
182+
maintenance_window_form = ServiceMaintenanceWindowForm(
183+
request.POST, instance=service
184+
)
185+
if maintenance_window_form.is_valid():
186+
new_maintenance_window = maintenance_window_form.cleaned_data[
187+
"maintenance_window"
188+
]
189+
if new_maintenance_window != maintenance_window:
190+
service.maintenance_window = new_maintenance_window
191+
service.save(update_fields=["maintenance_window"])
192+
service.customer.interaction_set.create(
193+
origin=Interaction.Origin.MAINTENANCE_WINDOW,
194+
summary=f"Maintenance window updated for service {service.pk}",
195+
content=(
196+
f"Maintenance window: {new_maintenance_window or 'not set'}"
197+
),
198+
user=request.user,
199+
)
200+
return redirect(service)
201+
show_form_errors(self.request, maintenance_window_form)
202+
return redirect(service)
203+
171204
subscription = service.subscription_set.get(pk=request.POST["subscription"])
172205
if "quote" in request.POST or "invoice" in request.POST:
173206
form = CustomerReferenceForm(request.POST)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#
2+
# Copyright © Michal Čihař <michal@weblate.org>
3+
#
4+
# This file is part of Weblate <https://weblate.org/>
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
#
19+
20+
from __future__ import annotations
21+
22+
from django.db import migrations, models
23+
from django.utils.translation import gettext_lazy
24+
25+
26+
class Migration(migrations.Migration):
27+
dependencies = [
28+
("weblate_web", "0047_replace_markupfield"),
29+
]
30+
31+
operations = [
32+
migrations.AddField(
33+
model_name="service",
34+
name="maintenance_window",
35+
field=models.CharField(
36+
blank=True,
37+
max_length=200,
38+
verbose_name=gettext_lazy("Maintenance window"),
39+
),
40+
),
41+
]

weblate_web/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,9 @@ class Service(models.Model):
633633
limit_hosted_strings = models.IntegerField(default=0)
634634
created = models.DateTimeField(auto_now_add=True)
635635
note = models.TextField(blank=True)
636+
maintenance_window = models.CharField(
637+
verbose_name=gettext_lazy("Maintenance window"), max_length=200, blank=True
638+
)
636639
hosted_billing = models.IntegerField(default=0, db_index=True)
637640
discoverable = models.BooleanField(default=False, blank=True)
638641
site_url = models.URLField(
@@ -803,6 +806,14 @@ def extended_subscriptions(self) -> models.QuerySet[Subscription]:
803806
"-expires"
804807
)
805808

809+
@property
810+
def has_active_extended_support(self) -> bool:
811+
now = timezone.now()
812+
return any(
813+
subscription.enabled and subscription.expires >= now
814+
for subscription in self.extended_subscriptions
815+
)
816+
806817
@cached_property
807818
def premium_subscriptions(self) -> models.QuerySet[Subscription]:
808819
return self.subscription_set.filter(package__name="premium").order_by(

0 commit comments

Comments
 (0)