Skip to content

Commit 79080e4

Browse files
authored
Add subtitle and funding_link fields to projects (#640)
* Add subtitle and funding_link fields to projects * Make alternate name case sensitive (#641) * Make alternate name case sensitive * Add migration for alternate name case sensitivity
1 parent 785e2b6 commit 79080e4

File tree

8 files changed

+123
-7
lines changed

8 files changed

+123
-7
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Add funding link and subtitle fields to Project
2+
3+
Revision ID: 1d53330411fa
4+
Revises: 19f12fe539c7
5+
Create Date: 2025-10-29 08:04:04.437011
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
from sqlalchemy import Column, String
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "1d53330411fa"
17+
down_revision: Union[str, None] = "19f12fe539c7"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
for field in ["funding_link", "subtitle"]:
24+
op.add_column(
25+
table_name="project",
26+
column=Column(
27+
name=field,
28+
type_=String(length=1800),
29+
default=None,
30+
nullable=True,
31+
),
32+
)
33+
34+
35+
def downgrade() -> None:
36+
for field in ["funding_link", "subtitle"]:
37+
op.drop_column("project", field)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Make alternate name case sensitive
2+
3+
Revision ID: 79b2dda7e3be
4+
Revises: 1d53330411fa
5+
Create Date: 2025-10-29 08:14:27.873149
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
from sqlalchemy import String
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "79b2dda7e3be"
17+
down_revision: Union[str, None] = "1d53330411fa"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
op.execute("ALTER TABLE alternate_name DROP CONSTRAINT AlternateName_name_lowercase")
24+
op.drop_index(index_name="ix_alternate_name_name", table_name="alternate_name")
25+
op.alter_column(
26+
table_name="alternate_name",
27+
column_name="name",
28+
nullable=True,
29+
existing_nullable=True,
30+
default=None,
31+
existing_server_default=None,
32+
type_=String(length=1800),
33+
)
34+
35+
36+
def downgrade() -> None:
37+
pass
Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
from database.model.named_relation import NamedRelation
1+
from sqlalchemy import Column, String
2+
from sqlmodel import Field, SQLModel
23

4+
from database.model.field_length import LONG
35

4-
class AlternateName(NamedRelation, table=True): # type: ignore [call-arg]
6+
7+
class AlternateName(SQLModel, table=True): # type: ignore [call-arg]
58
__tablename__ = "alternate_name"
9+
10+
identifier: int = Field(default=None, primary_key=True)
11+
name: str = Field(
12+
sa_column=Column(String(length=LONG)),
13+
description="The term or text",
14+
)

src/database/model/ai_resource/resource.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ class RelationshipConfig(AIoDConcept.RelationshipConfig):
119119
description="An alias for the item, commonly used for the resource instead of the "
120120
"name.",
121121
_serializer=AttributeSerializer("name"),
122-
deserializer=FindByNameDeserializerList(AlternateName),
122+
deserializer=FindByNameDeserializerList(AlternateName, case_sensitive=True),
123123
example=["alias 1", "alias 2"],
124124
default_factory_pydantic=list,
125125
on_delete_trigger_orphan_deletion=lambda: [

src/database/model/named_relation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from pydantic import create_model
55
from sqlalchemy import CheckConstraint, Column, String
6-
from sqlalchemy.orm import declared_attr, backref
6+
from sqlalchemy.orm import declared_attr
77
from sqlmodel import SQLModel, Field, Relationship
88

99
from database.model.field_length import NORMAL, LONG

src/database/model/project/project.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
AttributeSerializer,
1414
FindByIdentifierDeserializerList,
1515
)
16-
from database.model.field_length import IDENTIFIER_LENGTH
16+
from database.model.field_length import IDENTIFIER_LENGTH, LONG
1717
from database.model.resource_read_and_create import resource_read, resource_create
1818
from versioning import Version, VersionedResource, VersionedResourceCollection, schema_transform
1919

@@ -34,6 +34,28 @@ class ProjectBase(AIResourceBase):
3434
schema_extra={"example": 1000000},
3535
default=None,
3636
)
37+
subtitle: str | None = Field(
38+
description="A subtitle or tagline for the project",
39+
schema_extra={
40+
"examples": [
41+
"Development and Deployment of the European AI-on-demand Platform",
42+
"Bringing AI Planning to the European AI On-Demand Platform",
43+
]
44+
},
45+
default=None,
46+
max_length=LONG,
47+
)
48+
funding_link: str | None = Field(
49+
description=(
50+
"Link with information about the funding, e.g., "
51+
"to the project's EU Funding & Tenders portal page."
52+
),
53+
schema_extra={
54+
"example": "https://cordis.europa.eu/programme/id/HORIZON_HORIZON-CL4-2021-HUMAN-01-02/en" # noqa: E501
55+
},
56+
default=None,
57+
max_length=LONG,
58+
)
3759

3860

3961
class Project(ProjectBase, AIResource, table=True): # type: ignore [call-arg]

src/database/model/serializers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ class FindByNameDeserializerList(DeSerializer[NamedRelation]):
168168
"""Deserialization of NamedRelations: uniquely identified by their name."""
169169

170170
clazz: type[NamedRelation]
171+
case_sensitive: bool = False
171172

172173
def deserialize(
173174
self, session: Session, name: list[str] | None, user: KeycloakUser | None = None
@@ -176,10 +177,14 @@ def deserialize(
176177
return []
177178
if not isinstance(name, list):
178179
raise ValueError("Expected a list. Do you need to use FindByNameDeserializer instead?")
179-
names = {n.casefold() for n in name}
180+
names = {n if self.case_sensitive else n.casefold() for n in name}
180181
query = select(self.clazz).where(self.clazz.name.in_(names)) # type: ignore[attr-defined]
181182
existing = list(session.scalars(query).all())
182-
names_not_found = names - {e.name.casefold() for e in existing}
183+
if self.case_sensitive:
184+
# The query above is not case sensitive (MySQL default), so we filter exact matches
185+
existing = [e for e in existing if e.name in names]
186+
existing_names = {e.name if self.case_sensitive else e.name.casefold() for e in existing}
187+
names_not_found = names - existing_names
183188
enforced_taxonomy = issubclass(self.clazz, Taxonomy) and (
184189
user is None or not user.is_connector
185190
)

src/tests/routers/resource_routers/test_router_project.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ def test_happy_path(
4040
body["coordinator"] = organisation.identifier
4141
body["produced"] = [dataset.identifier]
4242
body["used"] = [publication.identifier]
43+
funding_link = "https://foo.bar"
44+
subtitle = "Foo: Bar"
45+
body["funding_link"] = funding_link
46+
body["subtitle"] = subtitle
4347

4448
response = client.post("/projects", json=body, headers={"Authorization": "Fake token"})
4549
assert response.status_code == 200, response.json()
@@ -57,6 +61,8 @@ def test_happy_path(
5761
assert response_json["coordinator"] == organisation.identifier
5862
assert response_json["produced"] == [dataset.identifier]
5963
assert response_json["used"] == [publication.identifier]
64+
assert response_json["funding_link"] == funding_link
65+
assert response_json["subtitle"] == subtitle
6066

6167
# Cleanup, so that all resources can be deleted in the teardown
6268
body["funder"] = []

0 commit comments

Comments
 (0)