Skip to content

Commit b40667b

Browse files
committed
Merge branch 'develop'
2 parents b704fbe + e7d99ea commit b40667b

File tree

18 files changed

+400
-166
lines changed

18 files changed

+400
-166
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ on:
99

1010
jobs:
1111
build:
12-
runs-on: ubuntu-20.04
12+
runs-on: ubuntu-latest
1313
environment: test
1414
strategy:
1515
matrix:
@@ -21,7 +21,7 @@ jobs:
2121
DJANGO_SETTINGS_MODULE: project.settings_test
2222
services:
2323
postgres:
24-
image: postgis/postgis:13-3.3
24+
image: postgis/postgis:14-3.5
2525
env:
2626
POSTGRES_HOST_AUTH_METHOD: trust
2727
options: >-
@@ -53,6 +53,7 @@ jobs:
5353
python -m pip install --upgrade pip
5454
pip install -r requirements.txt
5555
pip install -r requirements-test.txt
56+
pip install -r requirements-dev.txt
5657
5758
- name: Run Python side code neatness tests
5859
run: |

.pre-commit-config.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
minimum_pre_commit_version: 2.12.1 # You MUST keep this version in sync with the version in requirements-dev.in
1+
minimum_pre_commit_version: 4.2.0 # You MUST keep this version in sync with the version in requirements-dev.in
22
default_language_version:
3-
python: python3.9
3+
python: python3.11
44
repos:
55
- repo: https://github.com/psf/black
6-
rev: 22.3.0 # You MUST keep this version in sync with the version in requirements-dev.in
6+
rev: 25.1.0 # You MUST keep this version in sync with the version in requirements-dev.in
77
hooks:
88
- id: black
99

1010
- repo: https://github.com/pycqa/flake8
11-
rev: 3.9.1 # You MUST keep this version in sync with the version in requirements-dev.in
11+
rev: 7.2.0 # You MUST keep this version in sync with the version in requirements-dev.in
1212
hooks:
1313
- id: flake8
1414

1515
- repo: https://github.com/pycqa/isort
16-
rev: 5.8.0 # You MUST keep this version in sync with the version in requirements-dev.in
16+
rev: 6.0.1 # You MUST keep this version in sync with the version in requirements-dev.in
1717
hooks:
1818
- id: isort

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.7.0] - 2025-04-28
11+
12+
### Added
13+
14+
- Delete outdated preliminary permits via cronjob ([cce6be3](https://github.com/City-of-Helsinki/parking-permits/commit/cce6be33db15d99ea449a90b5ea43da41adf32f3))
15+
16+
### Fixed
17+
18+
- Improve special character handling ([60aa672](https://github.com/City-of-Helsinki/parking-permits/commit/60aa6726ba12e00e103c9b373ab268110d6376dd))
19+
- Update open-ended end time calculation ([6eae382](https://github.com/City-of-Helsinki/parking-permits/commit/6eae38257e64476762f0379a790006eed27410b3))
20+
- Refund only confirmed extension requests ([6e704f1](https://github.com/City-of-Helsinki/parking-permits/commit/6e704f1cb400b1f3c047c29df9a180fa9b8c82fd))
21+
22+
### Dependencies
23+
24+
- Update requirements ([27b16bf](https://github.com/City-of-Helsinki/parking-permits/commit/27b16bf881bd887bc1443dd103a3d911378df694))
25+
1026
## [1.6.2] - 2025-03-06
1127

1228
### Fixed
1329

1430
- Fix permit getting wrong price ([859dd36](https://github.com/City-of-Helsinki/parking-permits/commit/859dd36b171d41eca38cc46d091f283a46ae2551))
15-
- Add hard limit for amount of returted products ([518cf4d](https://github.com/City-of-Helsinki/parking-permits/commit/518cf4db675329f9b148c79bef0c0e56bc134f5f))
31+
- Add hard limit for amount of returned products ([518cf4d](https://github.com/City-of-Helsinki/parking-permits/commit/518cf4db675329f9b148c79bef0c0e56bc134f5f))
32+
- Don't disable temporary vehicle immediately when ending permit ([01f84ad](https://github.com/City-of-Helsinki/parking-permits/commit/01f84ada0e7bab600f880e21d33c8fe57f7e0a4c))
1633

1734
## [1.6.1] - 2024-12-20
1835

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
services:
22
db:
33
platform: linux/amd64
4-
image: postgis/postgis:13-3.1
4+
image: postgis/postgis:14-3.5
55
volumes:
66
- database-volume:/var/lib/postgresql/data
77
environment:

parking_permits/management/commands/delete_draft_permits.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@ class Command(BaseCommand):
1818
def add_arguments(self, parser):
1919
parser.add_argument("--hours", type=int, default=0)
2020
parser.add_argument("--minutes", type=int, default=30)
21+
parser.add_argument("--preliminary_hours", type=int, default=72)
22+
parser.add_argument("--preliminary_minutes", type=int, default=0)
2123

2224
def handle(self, *args, **options):
2325
minutes = options["minutes"] + (60 * options["hours"])
2426
now = timezone.localtime()
2527
time_limit = now - timedelta(minutes=minutes)
2628

29+
preliminary_minutes = options["preliminary_minutes"] + (
30+
60 * options["preliminary_hours"]
31+
)
32+
preliminary_time_limit = now - timedelta(minutes=preliminary_minutes)
33+
2734
# Delete draft permits
2835
# NOTE: there should not be draft permits with orders, but exclude
2936
# these just in case.
@@ -42,6 +49,26 @@ def handle(self, *args, **options):
4249
else:
4350
self.stdout.write("No draft permits deleted")
4451

52+
# Delete preliminary permits
53+
# NOTE: there should not be preliminary permits with orders, but exclude
54+
# these just in case.
55+
56+
preliminary_permits = ParkingPermit.objects.annotate(
57+
has_orders=Exists(OrderItem.objects.filter(permit=OuterRef("pk")))
58+
).filter(
59+
has_orders=False,
60+
created_at__lt=preliminary_time_limit,
61+
status=ParkingPermitStatus.PRELIMINARY,
62+
)
63+
64+
if num_preliminary_permits := preliminary_permits.count():
65+
preliminary_permits.delete()
66+
self.stdout.write(
67+
f"{num_preliminary_permits} preliminary permit(s) deleted"
68+
)
69+
else:
70+
self.stdout.write("No preliminary permits deleted")
71+
4572
# Delete permit extension requests
4673
# These will all have FK to Order, so we don't want to delete them completely,
4774
# just mark as CANCELLED.

parking_permits/models/parking_permit.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,10 @@ def permit_prices(self):
380380
def is_valid(self):
381381
return self.status == ParkingPermitStatus.VALID
382382

383+
@property
384+
def is_closed(self):
385+
return self.status == ParkingPermitStatus.CLOSED
386+
383387
@property
384388
def is_open_ended(self):
385389
return self.contract_type == ContractType.OPEN_ENDED
@@ -432,6 +436,11 @@ def current_period_start_time(self):
432436
@property
433437
def current_period_end_time(self):
434438
if self.is_open_ended:
439+
# If open-ended permit is already renewed for the new period,
440+
# then use previous period end time
441+
now = timezone.now()
442+
if self.end_time and self.end_time - relativedelta(months=1) > now:
443+
return self.end_time - relativedelta(months=1)
435444
return self.end_time
436445
return self.current_period_end_time_with_fixed_months(self.months_used)
437446

@@ -987,11 +996,14 @@ def get_unused_order_items_for_all_orders(self):
987996
return unused_order_items
988997

989998
def get_unused_order_items_for_order(self, order):
999+
from .order import OrderStatus
1000+
9901001
unused_start_date = timezone.localdate(self.next_period_start_time)
9911002

9921003
order_items = order.order_items.filter(
9931004
end_time__date__gte=unused_start_date,
9941005
is_refunded=False,
1006+
order__status=OrderStatus.CONFIRMED,
9951007
permit=self,
9961008
).order_by("start_time")
9971009

parking_permits/services/traficom.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,12 @@ def fetch_vehicle_details(self, registration_number, permit=None):
170170
vehicle_identity = et.find(".//tunnus")
171171
registration_number_et = et.find(".//rekisteritunnus")
172172
if registration_number_et is not None and registration_number_et.text:
173-
registration_number = registration_number_et.text.encode("latin-1").decode(
174-
"utf-8"
175-
)
173+
try:
174+
registration_number = registration_number_et.text.encode(
175+
"latin-1"
176+
).decode("utf-8")
177+
except UnicodeDecodeError:
178+
registration_number = registration_number_et.text
176179

177180
owners_et = et.findall(".//omistajatHaltijat/omistajaHaltija")
178181
emissions = motor.findall("kayttovoimat/kayttovoima/kulutukset/kulutus")

parking_permits/tests/commands/test_delete_draft_permits.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def test_recent_draft_permits_not_deleted():
8989

9090

9191
@pytest.mark.django_db()
92-
def test_recent_draft_permits_not_deleted_with_hour_argument():
92+
def test_recent_draft_permits_not_deleted_with_hours_argument():
9393
ParkingPermitFactory(
9494
status=ParkingPermitStatus.DRAFT,
9595
)
@@ -98,3 +98,33 @@ def test_recent_draft_permits_not_deleted_with_hour_argument():
9898
)
9999
call_command("delete_draft_permits", hours=3)
100100
assert ParkingPermit.objects.exists()
101+
102+
103+
@pytest.mark.django_db()
104+
def test_preliminary_permits_deleted():
105+
ParkingPermitFactory(
106+
status=ParkingPermitStatus.PRELIMINARY,
107+
)
108+
ParkingPermit.objects.update(created_at=timezone.localtime() - timedelta(hours=73))
109+
call_command("delete_draft_permits")
110+
assert not ParkingPermit.objects.exists()
111+
112+
113+
@pytest.mark.django_db()
114+
def test_recent_preliminary_permits_not_deleted():
115+
ParkingPermitFactory(
116+
status=ParkingPermitStatus.PRELIMINARY,
117+
)
118+
ParkingPermit.objects.update(created_at=timezone.localtime() - timedelta(hours=10))
119+
call_command("delete_draft_permits")
120+
assert ParkingPermit.objects.exists()
121+
122+
123+
@pytest.mark.django_db()
124+
def test_preliminary_permits_not_deleted_with_preliminary_hours_argument():
125+
ParkingPermitFactory(
126+
status=ParkingPermitStatus.PRELIMINARY,
127+
)
128+
ParkingPermit.objects.update(created_at=timezone.localtime() - timedelta(hours=3))
129+
call_command("delete_draft_permits", preliminary_hours=30)
130+
assert ParkingPermit.objects.exists()

parking_permits/tests/models/test_parking_permit.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,9 @@ def test_should_cancel_all_extensions_on_end_permit(self):
273273
pending.refresh_from_db()
274274
assert pending.is_cancelled()
275275

276-
@freeze_time(timezone.make_aware(datetime(2021, 11, 20, 12, 10, 50)))
277-
def test_should_set_end_time_to_now_if_end_permit_immediately(self):
278-
start_time = timezone.make_aware(datetime(2021, 11, 15))
276+
@freeze_time(timezone.make_aware(datetime(2025, 4, 18, 12, 10, 50)))
277+
def test_fixed_period_permit_end_time_when_ended_immediately(self):
278+
start_time = timezone.make_aware(datetime(2025, 4, 15))
279279
end_time = get_end_time(start_time, 6)
280280
permit = ParkingPermitFactory(
281281
contract_type=ContractType.FIXED_PERIOD,
@@ -285,12 +285,27 @@ def test_should_set_end_time_to_now_if_end_permit_immediately(self):
285285
)
286286
permit.end_permit(ParkingPermitEndType.IMMEDIATELY)
287287
self.assertEqual(
288-
permit.end_time, timezone.make_aware(datetime(2021, 11, 20, 12, 10, 50))
288+
permit.end_time, timezone.make_aware(datetime(2025, 4, 18, 12, 10, 50))
289289
)
290290

291-
@freeze_time(timezone.make_aware(datetime(2021, 11, 20, 12, 10, 50)))
292-
def test_should_set_end_time_to_period_end_if_end_permit_after_current_period(self):
293-
start_time = timezone.make_aware(datetime(2021, 11, 15))
291+
@freeze_time(timezone.make_aware(datetime(2025, 4, 18, 12, 10, 50)))
292+
def test_open_ended_permit_end_time_when_ended_immediately(self):
293+
start_time = timezone.make_aware(datetime(2025, 4, 15))
294+
end_time = get_end_time(start_time, 1)
295+
permit = ParkingPermitFactory(
296+
contract_type=ContractType.OPEN_ENDED,
297+
start_time=start_time,
298+
end_time=end_time,
299+
month_count=1,
300+
)
301+
permit.end_permit(ParkingPermitEndType.IMMEDIATELY)
302+
self.assertEqual(
303+
permit.end_time, timezone.make_aware(datetime(2025, 4, 18, 12, 10, 50))
304+
)
305+
306+
@freeze_time(timezone.make_aware(datetime(2025, 4, 18, 12, 10, 50)))
307+
def test_fixed_period_permit_end_time_when_ended_after_current_period(self):
308+
start_time = timezone.make_aware(datetime(2025, 4, 15))
294309
end_time = get_end_time(start_time, 6)
295310
permit = ParkingPermitFactory(
296311
contract_type=ContractType.FIXED_PERIOD,
@@ -301,7 +316,39 @@ def test_should_set_end_time_to_period_end_if_end_permit_after_current_period(se
301316
permit.end_permit(ParkingPermitEndType.AFTER_CURRENT_PERIOD)
302317
self.assertEqual(
303318
permit.end_time,
304-
timezone.make_aware(datetime(2021, 12, 14, 23, 59, 59, 999999)),
319+
timezone.make_aware(datetime(2025, 5, 14, 23, 59, 59, 999999)),
320+
)
321+
322+
@freeze_time(timezone.make_aware(datetime(2025, 4, 18, 12, 10, 50)))
323+
def test_open_ended_permit_end_time_when_ended_after_current_period(self):
324+
start_time = timezone.make_aware(datetime(2025, 4, 15))
325+
end_time = get_end_time(start_time, 2)
326+
permit = ParkingPermitFactory(
327+
contract_type=ContractType.OPEN_ENDED,
328+
start_time=start_time,
329+
end_time=end_time,
330+
)
331+
permit.end_permit(ParkingPermitEndType.AFTER_CURRENT_PERIOD)
332+
self.assertEqual(
333+
permit.end_time,
334+
timezone.make_aware(datetime(2025, 5, 14, 23, 59, 59, 999999)),
335+
)
336+
337+
@freeze_time(timezone.make_aware(datetime(2025, 4, 18, 12, 10, 50)))
338+
def test_open_ended_permit_end_time_when_ended_after_current_period_and_already_renewed(
339+
self,
340+
):
341+
start_time = timezone.make_aware(datetime(2025, 3, 20))
342+
end_time = get_end_time(start_time, 2)
343+
permit = ParkingPermitFactory(
344+
contract_type=ContractType.OPEN_ENDED,
345+
start_time=start_time,
346+
end_time=end_time,
347+
)
348+
permit.end_permit(ParkingPermitEndType.AFTER_CURRENT_PERIOD)
349+
self.assertEqual(
350+
permit.end_time,
351+
timezone.make_aware(datetime(2025, 4, 19, 23, 59, 59, 999999)),
305352
)
306353

307354
@freeze_time(timezone.make_aware(datetime(2021, 11, 20, 12, 10, 50)))
@@ -721,7 +768,7 @@ def test_get_unused_order_items_return_unused_items(self):
721768
end_time=end_time,
722769
month_count=12,
723770
)
724-
Order.objects.create_for_permits([permit])
771+
Order.objects.create_for_permits([permit], status=OrderStatus.CONFIRMED)
725772
permit.refresh_from_db()
726773
permit.status = ParkingPermitStatus.VALID
727774
permit.save()

parking_permits/tests/services/test_traficom.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ def __init__(self, text="", status_code=200):
2121
self.status_code = status_code
2222

2323

24-
def get_mock_xml(filename):
24+
def get_mock_xml(filename, encoding="latin-1"):
2525
return (
2626
(pathlib.Path(__file__).parent / "mocks" / "traficom" / filename)
27-
.open("r", encoding="latin-1")
27+
.open("r", encoding=encoding)
2828
.read()
2929
)
3030

@@ -676,7 +676,7 @@ def test_fetch_vehicle_aoa_2(self):
676676
# Special chars in registration number
677677
with mock.patch(
678678
"requests.post",
679-
return_value=MockResponse(get_mock_xml("special_cases/AOA-2.xml")),
679+
return_value=MockResponse(get_mock_xml("special_cases/AOA-2.xml", "utf-8")),
680680
):
681681
registration_number = "ÄÖÅ-2"
682682
vehicle = self.traficom.fetch_vehicle_details(registration_number)
@@ -688,7 +688,7 @@ def test_fetch_vehicle_aoa_3(self):
688688
# Special chars in registration number
689689
with mock.patch(
690690
"requests.post",
691-
return_value=MockResponse(get_mock_xml("special_cases/AOA-3.xml")),
691+
return_value=MockResponse(get_mock_xml("special_cases/AOA-3.xml", "utf-8")),
692692
):
693693
registration_number = "ÄÖÅ-3"
694694
vehicle = self.traficom.fetch_vehicle_details(registration_number)

0 commit comments

Comments
 (0)