Skip to content

Commit af6317c

Browse files
authored
feat: 1343 populate spdx license tables from licenses as project (#1425)
* added license, rules, license-rules tables and populate rules table
1 parent 2457835 commit af6317c

File tree

6 files changed

+294
-23
lines changed

6 files changed

+294
-23
lines changed

functions-python/tasks_executor/README.md

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,48 +17,64 @@ Examples:
1717

1818
```json
1919
{
20-
"task": "rebuild_missing_validation_reports",
21-
"payload": {
22-
"dry_run": true,
23-
"filter_after_in_days": 14,
24-
"filter_statuses": ["active", "inactive", "future"]
25-
}
20+
"task": "rebuild_missing_validation_reports",
21+
"payload": {
22+
"dry_run": true,
23+
"filter_after_in_days": 14,
24+
"filter_statuses": ["active", "inactive", "future"]
25+
}
2626
}
2727
```
28+
2829
```json
2930
{
30-
"task": "rebuild_missing_bounding_boxes",
31-
"payload": {
32-
"dry_run": true,
33-
"after_date": "2025-06-01"
34-
}
31+
"task": "rebuild_missing_bounding_boxes",
32+
"payload": {
33+
"dry_run": true,
34+
"after_date": "2025-06-01"
35+
}
3536
}
3637
```
38+
3739
```json
3840
{
39-
"task": "refresh_materialized_view",
40-
"payload": {
41+
"task": "refresh_materialized_view",
42+
"payload": {
4143
"dry_run": true
4244
}
4345
}
4446
```
4547

4648
To get the list of supported tasks use:
49+
4750
```json
4851
{
49-
"name": "list_tasks",
50-
"payload": {}
52+
"name": "list_tasks",
53+
"payload": {}
5154
}
5255
```
56+
5357
To update the geolocation files precision:
58+
5459
```json
5560
{
56-
"task": "update_geojson_files_precision",
57-
"payload": {
58-
"dry_run": true,
59-
"data_type": "gtfs",
60-
"precision": 5,
61-
"limit": 10
62-
}
61+
"task": "update_geojson_files_precision",
62+
"payload": {
63+
"dry_run": true,
64+
"data_type": "gtfs",
65+
"precision": 5,
66+
"limit": 10
67+
}
6368
}
64-
```
69+
```
70+
71+
To populate license rules:
72+
73+
```json
74+
{
75+
"task": "populate_license_rules",
76+
"payload": {
77+
"dry_run": true
78+
}
79+
}
80+
```

functions-python/tasks_executor/src/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
)
4141
from tasks.data_import.import_jbda_feeds import import_jbda_handler
4242

43+
from tasks.licenses.populate_license_rules import (
44+
populate_license_rules_handler,
45+
)
46+
4347
init_logger()
4448
LIST_COMMAND: Final[str] = "list"
4549
tasks = {
@@ -82,6 +86,10 @@
8286
"description": "Imports JBDA data into the system.",
8387
"handler": import_jbda_handler,
8488
},
89+
"populate_license_rules": {
90+
"description": "Populates license rules in the database from a predefined JSON source.",
91+
"handler": populate_license_rules_handler,
92+
},
8593
}
8694

8795

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import logging
2+
3+
import requests
4+
from shared.database.database import with_db_session
5+
from shared.database_gen.sqlacodegen_models import Rule
6+
7+
RULES_JSON_URL = (
8+
"https://raw.githubusercontent.com/MobilityData/licenses-aas/main/data/rules.json"
9+
)
10+
11+
12+
def populate_license_rules_handler(payload):
13+
"""
14+
Handler for populating license rules.
15+
16+
Args:
17+
payload (dict): Incoming payload data.
18+
19+
"""
20+
(dry_run) = get_parameters(payload)
21+
return populate_license_rules_task(dry_run)
22+
23+
24+
@with_db_session
25+
def populate_license_rules_task(dry_run, db_session):
26+
"""
27+
Populates license rules in the database. This function is triggered by a Cloud Task.
28+
29+
Args:
30+
dry_run (bool): If True, the function will simulate the operation without making changes.
31+
db_session: Database session for executing queries.
32+
"""
33+
logging.info("Starting populate_license_rules_task with dry_run=%s", dry_run)
34+
35+
try:
36+
logging.info("Downloading rules from %s", RULES_JSON_URL)
37+
response = requests.get(RULES_JSON_URL, timeout=10)
38+
response.raise_for_status()
39+
rules_json = response.json()
40+
# Type mapping to satisfy the check constraint of Rule.type
41+
TYPE_MAPPING = {
42+
"permissions": "permission",
43+
"conditions": "condition",
44+
"limitations": "limitation",
45+
}
46+
47+
# Combine all rule lists from the three categories
48+
rules_data = []
49+
for rule_type, rule_list in rules_json.items():
50+
normalized_type = TYPE_MAPPING[rule_type]
51+
for rule_data in rule_list:
52+
rule_data["type"] = normalized_type
53+
rules_data.append(rule_data)
54+
55+
logging.info(
56+
"Loaded %d rules from %d categories.", len(rules_data), len(rules_json)
57+
)
58+
59+
if dry_run:
60+
logging.info("Dry run: would insert/update %d rules.", len(rules_data))
61+
else:
62+
for rule_data in rules_data:
63+
rule_object = Rule(
64+
name=rule_data.get("name"),
65+
label=rule_data.get("label"),
66+
description=rule_data.get("description"),
67+
type=rule_data.get("type"),
68+
)
69+
db_session.merge(rule_object)
70+
71+
logging.info(
72+
"Successfully upserted %d rules into the database.", len(rules_data)
73+
)
74+
75+
except requests.exceptions.RequestException as e:
76+
logging.error("Failed to download rules JSON file: %s", e)
77+
raise
78+
except Exception as e:
79+
logging.error("An error occurred while populating license rules: %s", e)
80+
db_session.rollback()
81+
raise
82+
83+
84+
def get_parameters(payload):
85+
"""
86+
Get parameters from the payload and environment variables.
87+
88+
Args:
89+
payload (dict): dictionary containing the payload data.
90+
Returns:
91+
tuple: (dry_run, after_date)
92+
"""
93+
dry_run = payload.get("dry_run", False)
94+
return dry_run
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import unittest
2+
from unittest.mock import patch, MagicMock
3+
import requests
4+
from sqlalchemy.exc import SQLAlchemyError
5+
6+
from tasks.licenses.populate_license_rules import (
7+
populate_license_rules_task,
8+
RULES_JSON_URL,
9+
)
10+
from shared.database_gen.sqlacodegen_models import Rule
11+
12+
13+
class TestPopulateLicenseRules(unittest.TestCase):
14+
def setUp(self):
15+
"""Set up mock data for tests."""
16+
self.mock_rules_json = {
17+
"permissions": [
18+
{
19+
"name": "commercial-use",
20+
"label": "Commercial Use",
21+
"description": "The licensed material may be used for commercial purposes.",
22+
}
23+
],
24+
"conditions": [
25+
{
26+
"name": "include-copyright",
27+
"label": "Include Copyright",
28+
"description": "A copy of the copyright and license notices must be included.",
29+
}
30+
],
31+
"limitations": [],
32+
}
33+
34+
@patch("tasks.licenses.populate_license_rules.requests.get")
35+
def test_populate_rules_success(self, mock_requests_get):
36+
"""Test successful population of license rules."""
37+
# Arrange
38+
mock_response = MagicMock()
39+
mock_response.json.return_value = self.mock_rules_json
40+
mock_response.raise_for_status.return_value = None
41+
mock_requests_get.return_value = mock_response
42+
43+
mock_db_session = MagicMock()
44+
45+
# Act
46+
populate_license_rules_task(dry_run=False, db_session=mock_db_session)
47+
48+
# Assert
49+
mock_requests_get.assert_called_once_with(RULES_JSON_URL, timeout=10)
50+
self.assertEqual(mock_db_session.merge.call_count, 2)
51+
52+
# Check that merge was called with correctly constructed Rule objects
53+
call_args_list = mock_db_session.merge.call_args_list
54+
55+
# Check first call
56+
arg1 = call_args_list[0].args[0]
57+
self.assertIsInstance(arg1, Rule)
58+
self.assertEqual(arg1.name, "commercial-use")
59+
self.assertEqual(arg1.type, "permission")
60+
61+
# Check second call
62+
arg2 = call_args_list[1].args[0]
63+
self.assertIsInstance(arg2, Rule)
64+
self.assertEqual(arg2.name, "include-copyright")
65+
self.assertEqual(arg2.type, "condition")
66+
67+
mock_db_session.rollback.assert_not_called()
68+
69+
@patch("tasks.licenses.populate_license_rules.requests.get")
70+
def test_populate_rules_dry_run(self, mock_requests_get):
71+
"""Test that no database changes are made during a dry run."""
72+
# Arrange
73+
mock_response = MagicMock()
74+
mock_response.json.return_value = self.mock_rules_json
75+
mock_response.raise_for_status.return_value = None
76+
mock_requests_get.return_value = mock_response
77+
78+
mock_db_session = MagicMock()
79+
80+
# Act
81+
populate_license_rules_task(dry_run=True, db_session=mock_db_session)
82+
83+
# Assert
84+
mock_requests_get.assert_called_once_with(RULES_JSON_URL, timeout=10)
85+
mock_db_session.merge.assert_not_called()
86+
mock_db_session.rollback.assert_not_called()
87+
88+
@patch("tasks.licenses.populate_license_rules.requests.get")
89+
def test_request_exception_handling(self, mock_requests_get):
90+
"""Test handling of a requests exception."""
91+
# Arrange
92+
mock_requests_get.side_effect = requests.exceptions.RequestException(
93+
"Network Error"
94+
)
95+
mock_db_session = MagicMock()
96+
97+
# Act & Assert
98+
with self.assertRaises(requests.exceptions.RequestException):
99+
populate_license_rules_task(dry_run=False, db_session=mock_db_session)
100+
101+
mock_db_session.merge.assert_not_called()
102+
mock_db_session.rollback.assert_not_called()
103+
104+
@patch("tasks.licenses.populate_license_rules.requests.get")
105+
def test_database_exception_handling(self, mock_requests_get):
106+
"""Test handling of a database exception during merge."""
107+
# Arrange
108+
mock_response = MagicMock()
109+
mock_response.json.return_value = self.mock_rules_json
110+
mock_response.raise_for_status.return_value = None
111+
mock_requests_get.return_value = mock_response
112+
113+
mock_db_session = MagicMock()
114+
mock_db_session.merge.side_effect = SQLAlchemyError("DB connection failed")
115+
116+
# Act & Assert
117+
with self.assertRaises(SQLAlchemyError):
118+
populate_license_rules_task(dry_run=False, db_session=mock_db_session)
119+
120+
self.assertTrue(mock_db_session.merge.called)
121+
mock_db_session.rollback.assert_called_once()
122+
123+
124+
if __name__ == "__main__":
125+
unittest.main()

liquibase/changelog.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,5 @@
7878
<!-- Materialized view recreated because of note data type change -->
7979
<include file="changes/feat_pt_154.sql" relativeToChangelogFile="true"/>
8080
<include file="changes/feat_1249.sql" relativeToChangelogFile="true"/>
81+
<include file="changes/feat_1343.sql" relativeToChangelogFile="true"/>
8182
</databaseChangeLog>

liquibase/changes/feat_1343.sql

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
CREATE TABLE license (
2+
id TEXT PRIMARY KEY,
3+
type TEXT NOT NULL CHECK (type IN ('standard', 'custom')),
4+
is_spdx BOOLEAN NOT NULL DEFAULT FALSE,
5+
name TEXT NOT NULL,
6+
url TEXT,
7+
description TEXT,
8+
content_txt TEXT,
9+
content_html TEXT,
10+
created_at TIMESTAMP,
11+
updated_at TIMESTAMP
12+
);
13+
14+
-- Unified rules table
15+
CREATE TABLE rules (
16+
name TEXT PRIMARY KEY,
17+
label TEXT NOT NULL,
18+
description TEXT,
19+
type TEXT NOT NULL CHECK (type IN ('permission', 'condition', 'limitation'))
20+
);
21+
22+
-- Join table for license-rule mappings
23+
CREATE TABLE license_rules (
24+
license_id TEXT REFERENCES license(id) ON DELETE CASCADE,
25+
rule_id TEXT REFERENCES rules(name) ON DELETE CASCADE,
26+
PRIMARY KEY (license_id, rule_id)
27+
);

0 commit comments

Comments
 (0)