diff --git a/README.md b/README.md index c7efc102c..1b81c93a0 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,10 @@ After the database container is running, you can run tests with Pytest from your +#### Staging Data + +To get staging data into the database run `python -m transfers.transfer` from the root directory of the project. + ### 🧭 Project Structure ```text app/ diff --git a/api/contact.py b/api/contact.py index 00dec32c7..a67e5a7b4 100644 --- a/api/contact.py +++ b/api/contact.py @@ -27,7 +27,7 @@ amp_editor_dependency, amp_viewer_dependency, ) -from db import ThingContactAssociation, Thing, Contact, Email, Phone, Address, adder +from db import ThingContactAssociation, Thing, Contact, Email, Phone, Address from schemas.contact import ( CreateContact, CreateAddress, @@ -42,7 +42,7 @@ UpdatePhone, UpdateAddress, ) -from services.crud_helper import model_patcher, model_deleter +from services.crud_helper import model_patcher, model_deleter, model_adder from services.contact_helper import ( add_contact, ) @@ -146,7 +146,7 @@ def create_address( :return: Response containing the added address """ try: - return adder(session, Address, address_data, user=user) + return model_adder(session, Address, address_data, user=user) except ProgrammingError as e: database_error_handler(address_data, e) @@ -162,7 +162,7 @@ def create_email( user: amp_admin_dependency, ) -> EmailResponse: try: - return adder(session, Email, email_data, user=user) + return model_adder(session, Email, email_data, user=user) except ProgrammingError as e: database_error_handler(email_data, e) @@ -178,7 +178,7 @@ def create_phone( user: amp_admin_dependency, ) -> PhoneResponse: try: - return adder(session, Phone, phone_data, user=user) + return model_adder(session, Phone, phone_data, user=user) except ProgrammingError as e: database_error_handler(phone_data, e) diff --git a/api/geochronology.py b/api/geochronology.py index d32f57f34..411071d86 100644 --- a/api/geochronology.py +++ b/api/geochronology.py @@ -15,7 +15,7 @@ # =============================================================================== from db.geochronology import GeochronologyAge from fastapi import APIRouter, Depends, status -from db import adder +from services.crud_helper import model_adder from db.engine import get_db_session from schemas.geochronology import CreateGeochronologyAge from sqlalchemy.orm import Session @@ -33,7 +33,7 @@ async def create_age( """ # Placeholder for actual implementation # return {"message": "Geochronology age created successfully.", "data": age} - return adder(session, GeochronologyAge, age) + return model_adder(session, GeochronologyAge, age) @router.get("/age", tags=["geochronology"]) diff --git a/api/group.py b/api/group.py index d1df69f1c..9e677ff24 100644 --- a/api/group.py +++ b/api/group.py @@ -24,10 +24,9 @@ editor_dependency, viewer_function, ) -from db import adder from db.group import Group from schemas.group import UpdateGroup, CreateGroup, GroupResponse -from services.crud_helper import model_patcher, model_deleter +from services.crud_helper import model_patcher, model_deleter, model_adder from services.query_helper import ( simple_get_by_id, paginated_all_getter, @@ -47,7 +46,7 @@ def create_group( """ Create a new group in the database. """ - return adder(session, Group, group_data, user=user) + return model_adder(session, Group, group_data, user=user) # @router.post( diff --git a/api/lexicon.py b/api/lexicon.py index c4769220b..ea032a031 100644 --- a/api/lexicon.py +++ b/api/lexicon.py @@ -13,31 +13,45 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, Query from fastapi_pagination.ext.sqlalchemy import paginate from sqlalchemy import select, func +from sqlalchemy.exc import ProgrammingError +from starlette.status import ( + HTTP_200_OK, + HTTP_201_CREATED, + HTTP_204_NO_CONTENT, + HTTP_409_CONFLICT, +) from api.pagination import CustomPage from core.dependencies import ( session_dependency, editor_dependency, admin_dependency, - viewer_dependency, viewer_function, ) -from db.engine import get_db_session -from db.lexicon import Category, LexiconTriple, Lexicon, TermCategoryAssociation +from db.lexicon import ( + LexiconCategory, + LexiconTerm, + LexiconTermCategoryAssociation, + LexiconTriple, +) from schemas.lexicon import ( CreateLexiconTerm, CreateLexiconCategory, - CreateTriple, + CreateLexiconTriple, LexiconTermResponse, LexiconCategoryResponse, + LexiconTripleResponse, + UpdateLexiconTerm, + UpdateLexiconCategory, + UpdateLexiconTriple, ) -from services.crud_helper import model_patcher -from services.lexicon import add_lexicon_term +from services.crud_helper import model_patcher, model_deleter, model_adder +from services.exceptions_helper import PydanticStyleException +from services.lexicon_helper import add_lexicon_term, add_lexicon_triple from services.query_helper import ( - simple_all_getter, paginated_all_getter, order_sort_filter, simple_get_by_id, @@ -48,30 +62,61 @@ ) +def database_error_handler( + payload: UpdateLexiconTriple, error: ProgrammingError +) -> None: + """ + Handle errors raised by the database when adding or updating a lexicon triple. + """ + + error_message = error.orig.args[0]["M"] + + if ( + error_message + == 'insert or update on table "lexicon_triple" violates foreign key constraint "lexicon_triple_subject_fkey"' + ): + detail = { + "loc": ["body", "subject"], + "msg": f"LexiconTerm with term {payload.subject} not found.", + "type": "value_error", + "input": {"subject": payload.subject}, + } + elif ( + error_message + == 'insert or update on table "lexicon_triple" violates foreign key constraint "lexicon_triple_object__fkey"' + ): + detail = { + "loc": ["body", "object_"], + "msg": f"LexiconTerm with term {payload.object_} not found.", + "type": "value_error", + "input": {"object_": payload.object_}, + } + + raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail]) + + +# POST ========================================================================= + + @router.post( "/category", - status_code=status.HTTP_201_CREATED, + status_code=HTTP_201_CREATED, ) def add_category( - category_data: CreateLexiconCategory, session=Depends(get_db_session) + category_data: CreateLexiconCategory, + session: session_dependency, + user: admin_dependency, ) -> LexiconCategoryResponse: """ Endpoint to add a category to the lexicon. """ - data = category_data.model_dump() - name = data["name"] - description = data.get("description", "") - - category = Category(name=name, description=description) - session.add(category) - session.commit() - return category + return model_adder(session, LexiconCategory, category_data, user=user) @router.post( "/term", summary="Add term", - status_code=status.HTTP_201_CREATED, + status_code=HTTP_201_CREATED, ) def add_term( term_data: CreateLexiconTerm, session: session_dependency, user: admin_dependency @@ -81,50 +126,70 @@ def add_term( """ data = term_data.model_dump() return add_lexicon_term( - session, data["term"], data["definition"], data["category"], user=user + session, data["term"], data["definition"], data["categories"], user=user ) @router.post( - "/triple/add", + "/triple", summary="Add triple", - status_code=status.HTTP_201_CREATED, + status_code=HTTP_201_CREATED, ) -def add_triple(triple_data: CreateTriple, session=Depends(get_db_session)): +def add_triple( + triple_data: CreateLexiconTriple, + session: session_dependency, + user: admin_dependency, +) -> LexiconTripleResponse: triple_data = triple_data.model_dump() subject = triple_data["subject"] predicate = triple_data["predicate"] object_ = triple_data["object_"] + return add_lexicon_triple(session, subject, predicate, object_, user=user) - if isinstance(subject, dict): - add_lexicon_term( - session, subject["term"], subject["definition"], subject["category"] - ) - subject = subject["term"] - if isinstance(object_, dict): - add_lexicon_term( - session, object_["term"], object_["definition"], object_["category"] - ) - object_ = object_["term"] +# PATCH ======================================================================== - triple = LexiconTriple(subject=subject, predicate=predicate, object_=object_) - session.add(triple) - session.commit() - return triple +@router.patch("/term/{term_id}", status_code=HTTP_200_OK) +def update_lexicon_term( + term_id: int, + term_data: UpdateLexiconTerm, + session: session_dependency, + user: editor_dependency, +) -> LexiconTermResponse: -@router.get("/term/{term_id}") -def get_lexicon_term(term_id: int, session: session_dependency): - return simple_get_by_id(session, Lexicon, term_id) + return model_patcher(session, LexiconTerm, term_id, term_data, user=user) -@router.get("/category/{category_id}") -def get_lexicon_category(category_id: int, session: session_dependency): - return simple_get_by_id(session, Category, category_id) +@router.patch("/category/{category_id}", status_code=HTTP_200_OK) +def update_lexicon_category( + category_id: int, + category_data: UpdateLexiconCategory, + session: session_dependency, + user: editor_dependency, +) -> LexiconCategoryResponse: + return model_patcher( + session, LexiconCategory, category_id, category_data, user=user + ) + + +@router.patch("/triple/{triple_id}", status_code=HTTP_200_OK) +def update_lexicon_triple( + triple_id: int, + triple_data: UpdateLexiconTriple, + session: session_dependency, + user: editor_dependency, +) -> LexiconTripleResponse: + try: + return model_patcher(session, LexiconTriple, triple_id, triple_data, user=user) + except ProgrammingError as e: + database_error_handler(triple_data, e) + +# GET ========================================================================== -@router.get("/term", summary="Get lexicon terms") + +@router.get("/term", summary="Get lexicon terms", status_code=HTTP_200_OK) def get_lexicon_terms( session: session_dependency, category: str | None = None, @@ -137,28 +202,32 @@ def get_lexicon_terms( Endpoint to retrieve lexicon terms. """ - sql = select(Lexicon) + sql = select(LexiconTerm) if category: sql = ( - sql.join(TermCategoryAssociation) - .join(Category) - .where(Category.name == category) + sql.join(LexiconTermCategoryAssociation) + .join(LexiconCategory) + .where(LexiconCategory.name == category) ) if term: - sql = sql.where(Lexicon.term.ilike(f"%{term}%")) + sql = sql.where(LexiconTerm.term.ilike(f"%{term}%")) # If sort is 'categories', we do not apply sorting or filtering if sort == "categories": sort = None order = None - sql = order_sort_filter(sql, Lexicon, sort=sort, order=order, filter_=filter_) + sql = order_sort_filter(sql, LexiconTerm, sort=sort, order=order, filter_=filter_) if order is None: - sql = sql.order_by(func.lower(Lexicon.term).asc()) + sql = sql.order_by(func.lower(LexiconTerm.term).asc()) return paginate(query=sql, conn=session) - # return paginated_all_getter(session, sql, filter_) + + +@router.get("/term/{term_id}", status_code=HTTP_200_OK) +def get_lexicon_term(term_id: int, session: session_dependency) -> LexiconTermResponse: + return simple_get_by_id(session, LexiconTerm, term_id) @router.get("/category") @@ -171,28 +240,70 @@ def get_lexicon_categories( """ Endpoint to retrieve lexicon categories. """ - return paginated_all_getter(session, Category, sort, order, filter_) + return paginated_all_getter(session, LexiconCategory, sort, order, filter_) -@router.patch("/term/{term_id}") -def update_lexicon_term( - term_id: int, - term_data: CreateLexiconTerm, +@router.get("/category/{category_id}") +def get_lexicon_category( + category_id: int, session: session_dependency +) -> LexiconCategoryResponse: + return simple_get_by_id(session, LexiconCategory, category_id) + + +@router.get("/triple", summary="Get lexicon triples", status_code=HTTP_200_OK) +async def get_lexicon_triples( session: session_dependency, - user: editor_dependency, + sort: str = "subject", + order: str = "asc", + filter_: str = Query(alias="filter", default=None), +) -> CustomPage[LexiconTripleResponse]: + """ + Endpoint to retrieve lexicon triples. + """ + return paginated_all_getter(session, LexiconTriple, sort, order, filter_) + + +@router.get("/triple/{triple_id}", status_code=HTTP_200_OK) +async def get_lexicon_triple( + triple_id: int, session: session_dependency +) -> LexiconTripleResponse: + return simple_get_by_id(session, LexiconTriple, triple_id) + + +# DELETE ======================================================================= + + +@router.delete( + "/term/{term_id}", + summary="Delete a lexicon term by ID", + status_code=HTTP_204_NO_CONTENT, +) +async def delete_lexicon_term( + session: session_dependency, user: admin_dependency, term_id: int ): + return model_deleter(session, LexiconTerm, term_id) + - return model_patcher(session, Lexicon, term_id, term_data, user=user) +@router.delete( + "/category/{category_id}", + summary="Delete a lexicon category by ID", + status_code=HTTP_204_NO_CONTENT, +) +async def delete_lexicon_category( + session: session_dependency, user: admin_dependency, category_id: int +): + return model_deleter(session, LexiconCategory, category_id) -@router.patch("/category/{category_id}") -def update_lexicon_category( - category_id: int, - category_data: CreateLexiconCategory, - session: session_dependency, - user: editor_dependency, +@router.delete( + "/triple/{triple_id}", + summary="Delete a lexicon triple by ID", + status_code=HTTP_204_NO_CONTENT, +) +async def delete_lexicon_triple( + session: session_dependency, user: admin_dependency, triple_id: int ): - return model_patcher(session, Category, category_id, category_data, user=user) + return model_deleter(session, LexiconTriple, triple_id) # ============= EOF ============================================= diff --git a/api/location.py b/api/location.py index 3352168ae..8241b1806 100644 --- a/api/location.py +++ b/api/location.py @@ -25,12 +25,11 @@ editor_dependency, viewer_dependency, ) -from db import adder from db.location import Location from schemas.location import CreateLocation, LocationResponse, UpdateLocation from services.geospatial_helper import make_within_wkt from services.query_helper import make_query, order_sort_filter, simple_get_by_id -from services.crud_helper import model_patcher, model_deleter +from services.crud_helper import model_patcher, model_deleter, model_adder from fastapi import APIRouter @@ -49,7 +48,7 @@ def create_location( """ Create a new sample location in the database. """ - return adder(session, Location, location_data, user=user) + return model_adder(session, Location, location_data, user=user) @router.patch( diff --git a/api/observation.py b/api/observation.py index 888783836..9493d4f7d 100644 --- a/api/observation.py +++ b/api/observation.py @@ -25,7 +25,7 @@ amp_viewer_dependency, viewer_dependency, ) -from db import Observation, adder +from db import Observation from schemas.observation import ( CreateGroundwaterLevelObservation, GroundwaterLevelObservationResponse, @@ -38,7 +38,7 @@ UpdateWaterChemistryObservation, UpdateGeothermalObservation, ) -from services.crud_helper import model_deleter +from services.crud_helper import model_deleter, model_adder from services.query_helper import simple_get_by_id from services.observation_helper import ( get_observations, @@ -59,7 +59,7 @@ def add_groundwater_level_observation( """ Add a new groundwater observation to the database. """ - return adder(session, Observation, obs_data, user=user) + return model_adder(session, Observation, obs_data, user=user) @router.post("/water-chemistry", status_code=HTTP_201_CREATED) @@ -72,7 +72,7 @@ def add_water_chemistry_observation( Add a new water chemistry observation to the database. This endpoint is currently a placeholder and does not implement any functionality. """ - return adder(session, Observation, obs_data, user=user) + return model_adder(session, Observation, obs_data, user=user) @router.post("/geothermal", status_code=HTTP_201_CREATED) @@ -85,7 +85,7 @@ def add_geothermal_observation( Add a new geothermal observation to the database. This endpoint is currently a placeholder and does not implement any functionality. """ - return adder(session, Observation, obs_data, user=user) + return model_adder(session, Observation, obs_data, user=user) # PATCH ======================================================================== diff --git a/api/sample.py b/api/sample.py index c9107237c..df701da0f 100644 --- a/api/sample.py +++ b/api/sample.py @@ -25,12 +25,11 @@ editor_dependency, viewer_dependency, ) -from db import adder from db.sample import Sample from schemas import ResourceNotFoundResponse from schemas.sample import SampleResponse, CreateSample, UpdateSample from services.query_helper import paginated_all_getter, simple_get_by_id -from services.crud_helper import model_patcher, model_deleter +from services.crud_helper import model_patcher, model_deleter, model_adder from services.exceptions_helper import PydanticStyleException router = APIRouter( @@ -79,7 +78,7 @@ def add_sample( Endpoint to add a sample. """ try: - return adder(session, Sample, sample_data, user=user) + return model_adder(session, Sample, sample_data, user=user) except (IntegrityError, ProgrammingError) as e: database_error_handler(sample_data, e) diff --git a/api/sensor.py b/api/sensor.py index 5239f3175..a44aab742 100644 --- a/api/sensor.py +++ b/api/sensor.py @@ -26,10 +26,9 @@ editor_dependency, viewer_dependency, ) -from db import adder, Observation, Sample -from db.sensor import Sensor +from db import Observation, Sample, Sensor from schemas.sensor import SensorResponse, CreateSensor, UpdateSensor -from services.crud_helper import model_patcher, model_deleter +from services.crud_helper import model_patcher, model_deleter, model_adder from services.exceptions_helper import PydanticStyleException from services.query_helper import order_sort_filter, simple_get_by_id @@ -45,7 +44,7 @@ def add_sensor( """ Add a sensor to the system. """ - return adder(session, Sensor, sensor_data, user=user) + return model_adder(session, Sensor, sensor_data, user=user) # ====== PATCH ================================================================= diff --git a/api/thing.py b/api/thing.py index 93dbd38af..c36d572a1 100644 --- a/api/thing.py +++ b/api/thing.py @@ -27,15 +27,14 @@ amp_admin_dependency, admin_dependency, editor_dependency, - amp_viewer_dependency, - viewer_dependency, - no_permission_dependency, + # amp_viewer_dependency, + # viewer_dependency, + # no_permission_dependency, viewer_function, amp_viewer_function, - no_permission_function, + # no_permission_function, amp_editor_dependency, ) -from db import adder from db.engine import get_db_session from db.location import LocationThingAssociation, Location from db.thing import Thing, WellScreen @@ -57,7 +56,7 @@ UpdateThingIdLink, UpdateWellScreen, ) -from services.crud_helper import model_patcher +from services.crud_helper import model_patcher, model_adder from services.query_helper import ( simple_get_by_id, paginated_all_getter, @@ -242,7 +241,7 @@ def create_thing_id_link( """ Create a new link between a thing and an alternate ID. """ - return adder(session, ThingIdLink, link_data, user=user) + return model_adder(session, ThingIdLink, link_data, user=user) @router.post( @@ -308,7 +307,7 @@ def create_wellscreen( """ Create a new well screen in the database. """ - return adder(session, WellScreen, well_screen_data, user=user) + return model_adder(session, WellScreen, well_screen_data, user=user) @router.patch("/{thing_id}", summary="Update thing") diff --git a/core/app.py b/core/app.py index 026d46aa4..de481db47 100644 --- a/core/app.py +++ b/core/app.py @@ -22,7 +22,7 @@ from db.base import Base from db.engine import engine, session_ctx -from services.lexicon import add_lexicon_term +from services.lexicon_helper import add_lexicon_term from .settings import settings @@ -68,7 +68,7 @@ def init_lexicon(path="./core/lexicon.json"): session, term_dict["term"], term_dict["definition"], - term_dict["category"], + term_dict["categories"], ) except DatabaseError as e: print( diff --git a/core/lexicon.json b/core/lexicon.json index 6113a7f72..527a0604d 100644 --- a/core/lexicon.json +++ b/core/lexicon.json @@ -1,136 +1,141 @@ [ - {"category": "qc_sample", "term": "original", "definition": ""}, - {"category": "qc_sample", "term": "duplicate", "definition": ""}, - - {"category": "unit", "term": "dimensionless", "definition": ""}, - {"category": "unit", "term": "ft", "definition": "feet"}, - {"category": "unit", "term": "ftbgs", "definition": "feet below ground surface"}, - {"category": "unit", "term": "F", "definition": "Fahrenheit"}, - {"category": "unit", "term": "mg/L", "definition": "Milligrams per Liter"}, - {"category": "unit", "term": "mW/m²", "definition": "milliwatts per square meter"}, - {"category": "unit", "term": "W/m²", "definition": "watts per square meter"}, - {"category": "unit", "term": "W/m·K", "definition": "watts per meter Kelvin"}, - {"category": "unit", "term": "m²/s", "definition": "square meters per second"}, - {"category": "unit", "term": "deg C", "definition": "degree Celsius"}, - - {"category": "observed_property", "term": "groundwater level:groundwater level", "definition": "groundwater level measurement" }, - - {"category": "observed_property", "term": "geothermal:temperature", "definition": "Temperature measurement"}, - {"category": "observed_property", "term": "water chemistry:pH", "definition": "pH"}, - {"category": "observed_property", "term": "water chemistry:Alkalinity as CaCO3", "definition": "Alkalinity as CaCO3"}, + {"categories": [{"name": "qc_sample", "description": null}], "term": "original", "definition": ""}, + {"categories": [{"name": "qc_sample", "description": null}], "term": "duplicate", "definition": ""}, + + {"categories": [{"name": "unit", "description": null}], "term": "dimensionless", "definition": ""}, + {"categories": [{"name": "unit", "description": null}], "term": "ft", "definition": "feet"}, + {"categories": [{"name": "unit", "description": null}], "term": "ftbgs", "definition": "feet below ground surface"}, + {"categories": [{"name": "unit", "description": null}], "term": "F", "definition": "Fahrenheit"}, + {"categories": [{"name": "unit", "description": null}], "term": "mg/L", "definition": "Milligrams per Liter"}, + {"categories": [{"name": "unit", "description": null}], "term": "mW/m²", "definition": "milliwatts per square meter"}, + {"categories": [{"name": "unit", "description": null}], "term": "W/m²", "definition": "watts per square meter"}, + {"categories": [{"name": "unit", "description": null}], "term": "W/m·K", "definition": "watts per meter Kelvin"}, + {"categories": [{"name": "unit", "description": null}], "term": "m²/s", "definition": "square meters per second"}, + {"categories": [{"name": "unit", "description": null}], "term": "deg C", "definition": "degree Celsius"}, + + {"categories": [{"name": "observed_property", "description": null}], "term": "groundwater level:groundwater level", "definition": "groundwater level measurement" }, + + {"categories": [{"name": "observed_property", "description": null}], "term": "geothermal:temperature", "definition": "Temperature measurement"}, + + {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:pH", "definition": "pH"}, + {"categories": [{"name": "observed_property", "description": null}], "term": "water chemistry:Alkalinity as CaCO3", "definition": "Alkalinity as CaCO3"}, - {"category": "release_status", "term": "draft", "definition": "draft version"}, - {"category": "release_status", "term": "provisional", "definition": "provisional version"}, - {"category": "release_status", "term": "final", "definition": "final version"}, - {"category": "release_status", "term": "published", "definition": "published version"}, - {"category": "release_status", "term": "archived", "definition": "archived version"}, - {"category": "release_status", "term": "public", "definition": "public version"}, - {"category": "release_status", "term": "private", "definition": "private version"}, - - {"category": "relation", "term": "same_as", "definition": "same as"}, - {"category": "relation", "term": "related_to", "definition": "related to"}, - - {"category": "sample_type", "term": "groundwater", "definition": "groundwater sample from a well"}, - - {"category": "thing_type", "term": "water well", "definition": "a hole drill into the ground to access groundwater"}, - {"category": "thing_type", "term": "spring", "definition": "a natural discharge of groundwater at the surface"}, - {"category": "thing_type", "term": "perennial stream", "definition": "that has a continuous flow of water throughout the year, even during drier periods."}, - {"category": "thing_type", "term": "ephemeral stream", "definition": "a stream that flows only briefly during and after precipitation events"}, - {"category": "thing_type", "term": "meteorological station", "definition": "a station that measures the weather conditions at a particular location"}, - - {"category": "level_status", "term": "dry", "definition": "well is dry"}, - {"category": "level_status", "term": "normal", "definition": "normal well water level status"}, - - {"category": "organization", "term": "USGS", "definition": "US Geological Survey"}, - {"category": "organization", "term": "TWDB", "definition": "Texas Water Development Board"}, - {"category": "organization", "term": "Unknown", "definition": "Unknown organization"}, - {"category": "organization", "term": "NMED", "definition": "New Mexico Environment Department"}, - - - {"category": "collection_method", "term": "manual", "definition": "manual sampling"}, - {"category": "collection_method", "term": "continuous", "definition": "continuous sampling"}, - - {"category": "country", "term": "United States", "definition": "United States of America"}, - {"category": "country", "term": "Canada", "definition": "Canada"}, - {"category": "country", "term": "Mexico", "definition": "Mexico"}, - {"category": "country", "term": "United Kingdom", "definition": "United Kingdom of Great Britain and Northern Ireland"}, - {"category": "country", "term": "Australia", "definition": "Australia"}, - {"category": "country", "term": "Germany", "definition": "Germany"}, - {"category": "country", "term": "France", "definition": "France"}, - {"category": "country", "term": "Japan", "definition": "Japan"}, - - {"category": "role", "term": "Owner", "definition": "Owner"}, - {"category": "role", "term": "Primary", "definition": "Primary"}, - {"category": "role", "term": "Secondary", "definition": "Secondary"}, - {"category": "role", "term": "Manager", "definition": "Manager"}, - {"category": "role", "term": "Operator", "definition": "Operator"}, - {"category": "role", "term": "Driller", "definition": "Driller"}, - {"category": "role", "term": "Geologist", "definition": "Geologist"}, - {"category": "role", "term": "Hydrologist", "definition": "Hydrologist"}, - {"category": "role", "term": "Hydrogeologist", "definition": "Hydrogeologist"}, - {"category": "role", "term": "Engineer", "definition": "Engineer"}, - {"category": "role", "term": "Technician", "definition": "Technician"}, - - {"category": "email_type", "term": "Primary", "definition": "Primary"}, - {"category": "email_type", "term": "Work", "definition": "Primary"}, - {"category": "email_type", "term": "Personal", "definition": "Primary"}, - {"category": "address_type", "term": "Primary", "definition": "Primary"}, - {"category": "address_type", "term": "Work", "definition": "Primary"}, - {"category": "address_type", "term": "Personal", "definition": "Primary"}, - {"category": "address_type", "term": "Mailing", "definition": "mailing"}, - {"category": "address_type", "term": "Physical", "definition": "physical"}, - {"category": "phone_type", "term": "Primary", "definition": "Primary"}, - {"category": "phone_type", "term": "Work", "definition": "Primary"}, - {"category": "phone_type", "term": "Home", "definition": "Primary"}, - {"category": "phone_type", "term": "Mobile", "definition": "Primary"}, - - {"category": "spring_type", "term": "Artesian", "definition": "artesian spring"}, - {"category": "spring_type", "term": "Ephemeral", "definition": "ephemeral spring"}, - {"category": "spring_type", "term": "Perennial", "definition": "perennial spring"}, - {"category": "spring_type", "term": "Thermal", "definition": "thermal spring"}, - {"category": "spring_type", "term": "Mineral", "definition": "mineral spring"}, - - {"category": "well_type", "term": "Exploration", "definition": "Exploration well"}, - {"category": "well_type", "term": "Monitoring", "definition": "Monitoring"}, - {"category": "well_type", "term": "Production", "definition": "Production"}, - {"category": "well_type", "term": "Injection", "definition": "Injection"}, - - - {"category": "casing_material", "term": "PVC", "definition": "Polyvinyl Chloride"}, - {"category": "casing_material", "term": "Steel", "definition": "Steel"}, - {"category": "casing_material", "term": "Concrete", "definition": "Concrete"}, - - - {"category": "quality_control_status", "term": "Provisional", "definition": "Provisional quality control status"}, - {"category": "quality_control_status", "term": "Approved", "definition": "Approved quality control status"}, - {"category": "quality_control_status", "term": "Rejected", "definition": "Rejected quality control status"}, - - - {"category": "drilling_fluid", "term": "mud", "definition": "drilling mud"}, - - - {"category": "geochronology", "term": "Ar/Ar", "definition": "Ar40/Ar39 geochronology"}, - {"category": "geochronology", "term": "AFT", "definition": "apatite fission track"}, - {"category": "geochronology", "term": "K/Ar", "definition": "Potassium-Argon dating"}, - {"category": "geochronology", "term": "U/Th", "definition": "Uranium/Thorium dating"}, - {"category": "geochronology", "term": "Rb/Sr", "definition": "Rubidium-Strontium dating"}, - {"category": "geochronology", "term": "U/Pb", "definition": "Uranium/Lead dating"}, - {"category": "geochronology", "term": "Lu/Hf", "definition": "Lutetium-Hafnium dating"}, - {"category": "geochronology", "term": "Re/Os", "definition": "Rhenium-Osmium dating"}, - {"category": "geochronology", "term": "Sm/Nd", "definition": "Samarium-Neodymium dating"}, - - - {"category": "publication_type", "term": "Map", "definition": "Map"}, - {"category": "publication_type", "term": "Report", "definition": "Report"}, - {"category": "publication_type", "term": "Dataset", "definition": "Dataset"}, - {"category": "publication_type", "term": "Model", "definition": "Model"}, - {"category": "publication_type", "term": "Software", "definition": "Software"}, - {"category": "publication_type", "term": "Paper", "definition": "Paper"}, - {"category": "publication_type", "term": "Thesis", "definition": "Thesis"}, - {"category": "publication_type", "term": "Book", "definition": "Book"}, - {"category": "publication_type", "term": "Conference", "definition": "Conference"}, - {"category": "publication_type", "term": "Webpage", "definition": "Webpage"} + + {"categories": [{"name": "release_status", "description": null}], "term": "draft", "definition": "draft version"}, + {"categories": [{"name": "release_status", "description": null}], "term": "provisional", "definition": "provisional version"}, + {"categories": [{"name": "release_status", "description": null}], "term": "final", "definition": "final version"}, + {"categories": [{"name": "release_status", "description": null}], "term": "published", "definition": "published version"}, + {"categories": [{"name": "release_status", "description": null}], "term": "archived", "definition": "archived version"}, + {"categories": [{"name": "release_status", "description": null}], "term": "public", "definition": "public version"}, + {"categories": [{"name": "release_status", "description": null}], "term": "private", "definition": "private version"}, + + {"categories": [{"name": "relation", "description": null}], "term": "same_as", "definition": "same as"}, + {"categories": [{"name": "relation", "description": null}], "term": "related_to", "definition": "related to"}, + + {"categories": [{"name": "sample_type", "description": null}], "term": "groundwater", "definition": "groundwater sample from a well"}, + + {"categories": [{"name": "thing_type", "description": null}], "term": "water well", "definition": "a hole drill into the ground to access groundwater"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "spring", "definition": "a natural discharge of groundwater at the surface"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "perennial stream", "definition": "that has a continuous flow of water throughout the year, even during drier periods."}, + {"categories": [{"name": "thing_type", "description": null}], "term": "ephemeral stream", "definition": "a stream that flows only briefly during and after precipitation events"}, + {"categories": [{"name": "thing_type", "description": null}], "term": "meteorological station", "definition": "a station that measures the weather conditions at a particular location"}, + + {"categories": [{"name": "level_status", "description": null}], "term": "dry", "definition": "well is dry"}, + {"categories": [{"name": "level_status", "description": null}], "term": "normal", "definition": "normal well water level status"}, + + + {"categories": [{"name": "organization", "description": null}], "term": "USGS", "definition": "US Geological Survey"}, + {"categories": [{"name": "organization", "description": null}], "term": "TWDB", "definition": "Texas Water Development Board"}, + {"categories": [{"name": "organization", "description": null}], "term": "Unknown", "definition": "Unknown organization"}, + {"categories": [{"name": "organization", "description": null}], "term": "NMED", "definition": "New Mexico Environment Department"}, + + + {"categories": [{"name": "collection_method", "description": null}], "term": "manual", "definition": "manual sampling"}, + {"categories": [{"name": "collection_method", "description": null}], "term": "continuous", "definition": "continuous sampling"}, + + {"categories": [{"name": "country", "description": null}], "term": "United States", "definition": "United States of America"}, + {"categories": [{"name": "country", "description": null}], "term": "Canada", "definition": "Canada"}, + {"categories": [{"name": "country", "description": null}], "term": "Mexico", "definition": "Mexico"}, + {"categories": [{"name": "country", "description": null}], "term": "United Kingdom", "definition": "United Kingdom of Great Britain and Northern Ireland"}, + {"categories": [{"name": "country", "description": null}], "term": "Australia", "definition": "Australia"}, + {"categories": [{"name": "country", "description": null}], "term": "Germany", "definition": "Germany"}, + {"categories": [{"name": "country", "description": null}], "term": "France", "definition": "France"}, + {"categories": [{"name": "country", "description": null}], "term": "Japan", "definition": "Japan"}, + + {"categories": [{"name": "role", "description": null}], "term": "Owner", "definition": "Owner"}, + {"categories": [{"name": "role", "description": null}], "term": "Primary", "definition": "Primary"}, + {"categories": [{"name": "role", "description": null}], "term": "Secondary", "definition": "Secondary"}, + {"categories": [{"name": "role", "description": null}], "term": "Manager", "definition": "Manager"}, + {"categories": [{"name": "role", "description": null}], "term": "Operator", "definition": "Operator"}, + {"categories": [{"name": "role", "description": null}], "term": "Driller", "definition": "Driller"}, + {"categories": [{"name": "role", "description": null}], "term": "Geologist", "definition": "Geologist"}, + {"categories": [{"name": "role", "description": null}], "term": "Hydrologist", "definition": "Hydrologist"}, + {"categories": [{"name": "role", "description": null}], "term": "Hydrogeologist", "definition": "Hydrogeologist"}, + {"categories": [{"name": "role", "description": null}], "term": "Engineer", "definition": "Engineer"}, + {"categories": [{"name": "role", "description": null}], "term": "Technician", "definition": "Technician"}, + + + {"categories": [{"name": "email_type", "description": null}], "term": "Primary", "definition": "Primary"}, + {"categories": [{"name": "email_type", "description": null}], "term": "Work", "definition": "Primary"}, + {"categories": [{"name": "email_type", "description": null}], "term": "Personal", "definition": "Primary"}, + {"categories": [{"name": "address_type", "description": null}], "term": "Primary", "definition": "Primary"}, + {"categories": [{"name": "address_type", "description": null}], "term": "Work", "definition": "Primary"}, + {"categories": [{"name": "address_type", "description": null}], "term": "Personal", "definition": "Primary"}, + {"categories": [{"name": "address_type", "description": null}], "term": "Mailing", "definition": "mailing"}, + {"categories": [{"name": "address_type", "description": null}], "term": "Physical", "definition": "physical"}, + {"categories": [{"name": "phone_type", "description": null}], "term": "Primary", "definition": "Primary"}, + {"categories": [{"name": "phone_type", "description": null}], "term": "Work", "definition": "Primary"}, + {"categories": [{"name": "phone_type", "description": null}], "term": "Home", "definition": "Primary"}, + {"categories": [{"name": "phone_type", "description": null}], "term": "Mobile", "definition": "Primary"}, + + {"categories": [{"name": "spring_type", "description": null}], "term": "Artesian", "definition": "artesian spring"}, + {"categories": [{"name": "spring_type", "description": null}], "term": "Ephemeral", "definition": "ephemeral spring"}, + {"categories": [{"name": "spring_type", "description": null}], "term": "Perennial", "definition": "perennial spring"}, + {"categories": [{"name": "spring_type", "description": null}], "term": "Thermal", "definition": "thermal spring"}, + {"categories": [{"name": "spring_type", "description": null}], "term": "Mineral", "definition": "mineral spring"}, + + {"categories": [{"name": "well_type", "description": null}], "term": "Exploration", "definition": "Exploration well"}, + {"categories": [{"name": "well_type", "description": null}], "term": "Monitoring", "definition": "Monitoring"}, + {"categories": [{"name": "well_type", "description": null}], "term": "Production", "definition": "Production"}, + {"categories": [{"name": "well_type", "description": null}], "term": "Injection", "definition": "Injection"}, + + + + {"categories": [{"name": "casing_material", "description": null}], "term": "PVC", "definition": "Polyvinyl Chloride"}, + {"categories": [{"name": "casing_material", "description": null}], "term": "Steel", "definition": "Steel"}, + {"categories": [{"name": "casing_material", "description": null}], "term": "Concrete", "definition": "Concrete"}, + + + {"categories": [{"name": "quality_control_status", "description": null}], "term": "Provisional", "definition": "Provisional quality control status"}, + {"categories": [{"name": "quality_control_status", "description": null}], "term": "Approved", "definition": "Approved quality control status"}, + {"categories": [{"name": "quality_control_status", "description": null}], "term": "Rejected", "definition": "Rejected quality control status"}, + + + {"categories": [{"name": "drilling_fluid", "description": null}], "term": "mud", "definition": "drilling mud"}, + + + {"categories": [{"name": "geochronology", "description": null}], "term": "Ar/Ar", "definition": "Ar40/Ar39 geochronology"}, + {"categories": [{"name": "geochronology", "description": null}], "term": "AFT", "definition": "apatite fission track"}, + {"categories": [{"name": "geochronology", "description": null}], "term": "K/Ar", "definition": "Potassium-Argon dating"}, + {"categories": [{"name": "geochronology", "description": null}], "term": "U/Th", "definition": "Uranium/Thorium dating"}, + {"categories": [{"name": "geochronology", "description": null}], "term": "Rb/Sr", "definition": "Rubidium-Strontium dating"}, + {"categories": [{"name": "geochronology", "description": null}], "term": "U/Pb", "definition": "Uranium/Lead dating"}, + {"categories": [{"name": "geochronology", "description": null}], "term": "Lu/Hf", "definition": "Lutetium-Hafnium dating"}, + {"categories": [{"name": "geochronology", "description": null}], "term": "Re/Os", "definition": "Rhenium-Osmium dating"}, + {"categories": [{"name": "geochronology", "description": null}], "term": "Sm/Nd", "definition": "Samarium-Neodymium dating"}, + + + {"categories": [{"name": "publication_type", "description": null}], "term": "Map", "definition": "Map"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Report", "definition": "Report"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Dataset", "definition": "Dataset"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Model", "definition": "Model"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Software", "definition": "Software"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Paper", "definition": "Paper"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Thesis", "definition": "Thesis"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Book", "definition": "Book"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Conference", "definition": "Conference"}, + {"categories": [{"name": "publication_type", "description": null}], "term": "Webpage", "definition": "Webpage"} ] \ No newline at end of file diff --git a/db/__init__.py b/db/__init__.py index f92a86078..6d71baf61 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -49,27 +49,6 @@ configure_mappers() -def adder(session, table, model, user=None, **kwargs): - """ - Helper function to add a new record to the database. - """ - - md = model.model_dump() - if kwargs: - md.update(kwargs) - - if user: - # TODO: see note in "AuditMixin" - md["created_by_id"] = user["sub"] - md["created_by_name"] = user["name"] - - obj = table(**md) - session.add(obj) - session.commit() - session.refresh(obj) - return obj - - def search(query, search_query, vector=None, regconfig=None, sort=True, limit=None): if not search_query.strip(): return query diff --git a/db/lexicon.py b/db/lexicon.py index eb46a6912..4c8f68f63 100644 --- a/db/lexicon.py +++ b/db/lexicon.py @@ -13,13 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from sqlalchemy import String, Integer, ForeignKey +from sqlalchemy import String, ForeignKey, Integer from sqlalchemy.orm import mapped_column, relationship +from sqlalchemy.ext.associationproxy import association_proxy from db.base import AutoBaseMixin, Base, lexicon_term -class Lexicon(Base, AutoBaseMixin): +class LexiconTerm(Base, AutoBaseMixin): """ Lexicon model for storing terms and their definitions. This model can be extended to include additional fields as needed. @@ -29,16 +30,18 @@ class Lexicon(Base, AutoBaseMixin): term = mapped_column(String(100), unique=True, nullable=False) definition = mapped_column(String(255), nullable=False) - # categories = relationship( - # "Category", - # secondary="lexicon_term_category_association", - # ) - # categories = relationship("TermCategoryAssociation") + category_associations = relationship( + "LexiconTermCategoryAssociation", + back_populates="term", + cascade="all, delete-orphan", + ) + categories = association_proxy("category_associations", "category") + def __repr__(self): - return f"" + return f"" -class Category(Base, AutoBaseMixin): +class LexiconCategory(Base, AutoBaseMixin): """ Model for storing categories of terms. This can be used to group terms into different categories. @@ -49,10 +52,10 @@ class Category(Base, AutoBaseMixin): description = mapped_column(String(255), nullable=True) def __repr__(self): - return f"" + return f"" -class TermCategoryAssociation(Base, AutoBaseMixin): +class LexiconTermCategoryAssociation(Base, AutoBaseMixin): """ Model for linking terms to categories. This can be used to create a many-to-many relationship between terms and categories. @@ -60,18 +63,20 @@ class TermCategoryAssociation(Base, AutoBaseMixin): __tablename__ = "lexicon_term_category_association" - lexicon_term = lexicon_term(foreignkeykw={"ondelete": "CASCADE"}) - category_name = mapped_column( - String(255), - ForeignKey("lexicon_category.name", ondelete="CASCADE"), + term_id = mapped_column( + Integer, ForeignKey("lexicon_term.id", ondelete="CASCADE"), nullable=False + ) + category_id = mapped_column( + Integer, + ForeignKey("lexicon_category.id", ondelete="CASCADE"), nullable=False, ) - term = relationship("Lexicon", backref="categories") - category = relationship("Category") + term = relationship("LexiconTerm") + category = relationship("LexiconCategory") def __repr__(self): - return f"" + return f"" class LexiconTriple(Base, AutoBaseMixin): @@ -80,12 +85,16 @@ class LexiconTriple(Base, AutoBaseMixin): This can be used to represent relationships between terms. """ - subject = lexicon_term(nullable=False) + subject = lexicon_term(nullable=False, foreignkeykw={"ondelete": "CASCADE"}) predicate = mapped_column(String(100), nullable=False) - object_ = lexicon_term(nullable=False) + object_ = lexicon_term(nullable=False, foreignkeykw={"ondelete": "CASCADE"}) - subject_term = relationship("Lexicon", foreign_keys=[subject]) - object_term = relationship("Lexicon", foreign_keys=[object_]) + subject_term = relationship( + "LexiconTerm", foreign_keys=[subject], passive_deletes=True + ) + object_term = relationship( + "LexiconTerm", foreign_keys=[object_], passive_deletes=True + ) def __repr__(self): return f"" diff --git a/schemas/lexicon.py b/schemas/lexicon.py index f9ade729e..172385a35 100644 --- a/schemas/lexicon.py +++ b/schemas/lexicon.py @@ -14,34 +14,34 @@ # limitations under the License. # =============================================================================== from pydantic import BaseModel -from typing import List, Optional +from typing import List from schemas import ORMBaseModel # -------- CREATE ---------- -class CreateLexiconTerm(BaseModel): +class CreateLexiconCategory(BaseModel): """ - Pydantic model for creating a lexicon term. + Pydantic model for creating a lexicon category. This model can be extended to include additional fields as needed. """ - term: str - definition: str - category: str | int | None = None + name: str + description: str | None = None -class CreateLexiconCategory(BaseModel): +class CreateLexiconTerm(BaseModel): """ - Pydantic model for creating a lexicon category. + Pydantic model for creating a lexicon term. This model can be extended to include additional fields as needed. """ - name: str - description: str | None = None + term: str + definition: str + categories: list[CreateLexiconCategory] -class CreateTriple(BaseModel): +class CreateLexiconTriple(BaseModel): """ Pydantic model for creating a triple. This model can be extended to include additional fields as needed. @@ -52,6 +52,25 @@ class CreateTriple(BaseModel): object_: CreateLexiconTerm +# UPDATE ======================================================================= + + +class UpdateLexiconCategory(BaseModel): + name: str | None = None + description: str | None = None + + +class UpdateLexiconTerm(BaseModel): + term: str | None = None + definition: str | None = None + + +class UpdateLexiconTriple(BaseModel): + subject: str | None = None + predicate: str | None = None + object_: str | None = None + + # -------- RESPONSE ---------- @@ -61,21 +80,11 @@ class LexiconCategoryResponse(ORMBaseModel): This model can be extended to include additional fields as needed. """ - id: int name: str description: str | None = None # terms: list[LexiconTermResponse] | None = None -class LexiconTermCategoryResponse(ORMBaseModel): - """ - Pydantic model for the response of a lexicon term category association. - This model can be extended to include additional fields as needed. - """ - - category: LexiconCategoryResponse - - class LexiconTermResponse(ORMBaseModel): """ Pydantic model for the response of a lexicon term. @@ -84,8 +93,13 @@ class LexiconTermResponse(ORMBaseModel): term: str definition: str - categories: List[LexiconTermCategoryResponse] | None = None + categories: List[LexiconCategoryResponse] = [] + + +class LexiconTripleResponse(ORMBaseModel): + subject: str + predicate: str + object_: str -# -------- UPDATE ---------- # ============= EOF ============================================= diff --git a/services/crud_helper.py b/services/crud_helper.py index 1d1878f5d..daa4bbf3d 100644 --- a/services/crud_helper.py +++ b/services/crud_helper.py @@ -21,6 +21,27 @@ from services.query_helper import simple_get_by_id +def model_adder(session, table, model, user=None, **kwargs): + """ + Helper function to add a new record to the database. + """ + + md = model.model_dump() + if kwargs: + md.update(kwargs) + + if user: + # TODO: see note in "AuditMixin" + md["created_by_id"] = user["sub"] + md["created_by_name"] = user["name"] + + obj = table(**md) + session.add(obj) + session.commit() + session.refresh(obj) + return obj + + def model_patcher( session: Session, model: DeclarativeBase, diff --git a/services/lexicon.py b/services/lexicon.py deleted file mode 100644 index d076c986c..000000000 --- a/services/lexicon.py +++ /dev/null @@ -1,64 +0,0 @@ -# =============================================================================== -# Copyright 2025 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -from db.lexicon import Category, Lexicon, TermCategoryAssociation -from sqlalchemy.orm import Session -from sqlalchemy import select - -from services.audit_helper import audit_add - - -def add_lexicon_term( - session: Session, term: str, definition: str, category: str | int, user: dict = None -) -> Lexicon: - """ - Add a term to the lexicon with its definition and category. - - """ - if isinstance(category, str): - sql = select(Category).where(Category.name == category) - dbcategory = session.scalars(sql).one_or_none() - if dbcategory is None: - # Create a new category if it does not exist - dbcategory = Category(name=category) - audit_add(user, dbcategory) - session.add(dbcategory) - session.commit() - session.flush() - else: - dbcategory = session.get(Category, category) - - # Check if the term already exists - sql = select(Lexicon).where(Lexicon.term == term) - dbterm = session.scalars(sql).one_or_none() - if dbterm is None: - dbterm = Lexicon(term=term, definition=definition) - audit_add(user, dbterm) - session.add(dbterm) - - if dbcategory is not None: - link = TermCategoryAssociation() - - link.category = dbcategory - link.term = dbterm - audit_add(user, link) - session.add(link) - - session.commit() - - return dbterm - - -# ============= EOF ============================================= diff --git a/services/lexicon_helper.py b/services/lexicon_helper.py new file mode 100644 index 000000000..c4a98454b --- /dev/null +++ b/services/lexicon_helper.py @@ -0,0 +1,143 @@ +# =============================================================================== +# Copyright 2025 ross +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =============================================================================== +from db.engine import get_db_session +from db.lexicon import ( + LexiconCategory, + LexiconTerm, + LexiconTermCategoryAssociation, + LexiconTriple, +) +from sqlalchemy.orm import Session +from sqlalchemy import select + +from services.audit_helper import audit_add + + +def add_lexicon_term( + session: Session, + term: str, + definition: str, + categories: list | None, + user: dict = None, +) -> LexiconTerm: + """ + Add a term to the lexicon with its definition and category. + + """ + db_categories = [] + if isinstance(categories, list): + + category_names = [c.get("name") for c in categories] + + sql = select(LexiconCategory).where(LexiconCategory.name.in_(category_names)) + associated_categories = session.scalars(sql).all() + associated_category_names = [c.name for c in associated_categories] + + unassociated_categories = [ + category + for category in categories + if category.get("name") not in associated_category_names + ] + for category in unassociated_categories: + # Create a new category if it does not exist + category = LexiconCategory( + name=category.get("name"), description=category.get("description") + ) + audit_add(user, category) + session.add(category) + session.commit() + session.flush() + + db_categories.append(category) + + db_categories.extend(associated_categories) + + # Check if the term already exists + sql = select(LexiconTerm).where(LexiconTerm.term == term) + dbterm = session.scalars(sql).one_or_none() + if dbterm is None: + dbterm = LexiconTerm(term=term, definition=definition) + audit_add(user, dbterm) + session.add(dbterm) + + if len(db_categories) > 0: + for category in db_categories: + link = LexiconTermCategoryAssociation() + + link.category = category + link.term = dbterm + audit_add(user, link) + session.add(link) + + session.commit() + + return dbterm + + +def add_lexicon_triple( + session: Session, + subject: dict, + predicate: str, + object_: dict, + user: dict = None, +) -> LexiconTriple: + """ + Add a triple to the lexicon. + """ + # add subject and object to db if they don't already exist + for term in subject, object_: + if isinstance(term, dict): + sql = select(LexiconTerm).where(LexiconTerm.term == term["term"]) + existing_term = session.scalars(sql).one_or_none() + if existing_term is None: + add_lexicon_term( + session, + term["term"], + term["definition"], + term["categories"], + user=user, + ) + + triple = LexiconTriple( + subject=subject["term"], predicate=predicate, object_=object_["term"] + ) + audit_add(user, triple) + session.add(triple) + session.commit() + return triple + + +def get_terms_by_category(category: str) -> list: + """ + Fetches the terms from the database by category. + + Returns: + list: A list of terms. + """ + + with next(get_db_session()) as session: + + sql = select(LexiconTerm) + sql = sql.join(LexiconTermCategoryAssociation) + sql = sql.join(LexiconCategory) + sql = sql.filter(LexiconCategory.name == category) + + categories = [lex.term for lex in session.scalars(sql).all()] + + return categories + + +# ============= EOF ============================================= diff --git a/services/validation/__init__.py b/services/validation/__init__.py index b16da56dd..850ec5839 100644 --- a/services/validation/__init__.py +++ b/services/validation/__init__.py @@ -13,30 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from sqlalchemy import select - -from db.engine import get_db_session -from db.lexicon import Lexicon, Category, TermCategoryAssociation - - -def get_category(category: str) -> list: - """ - Fetches the categories from the database. - - Returns: - list: A list of categories. - """ - - session = next(get_db_session()) - - sql = select(Lexicon) - sql = sql.join(TermCategoryAssociation) - sql = sql.join(Category) - sql = sql.filter(Category.name == category) - - categories = [lex.term for lex in session.scalars(sql).all()] - - return categories # ============= EOF ============================================= diff --git a/services/validation/well.py b/services/validation/well.py index edd890a3e..253aaa95f 100644 --- a/services/validation/well.py +++ b/services/validation/well.py @@ -14,7 +14,7 @@ # limitations under the License. # =============================================================================== from schemas.thing import CreateWellScreen -from services.validation import get_category +from services.lexicon_helper import get_terms_by_category async def validate_screens(well_screen_data: CreateWellScreen): @@ -26,7 +26,7 @@ async def validate_screens(well_screen_data: CreateWellScreen): # session = database_sessionmaker() # with session: # get valid screen types from the database - valid_screen_types = get_category("casing_material") + valid_screen_types = get_terms_by_category("casing_material") if ( well_screen_data.screen_type and well_screen_data.screen_type not in valid_screen_types diff --git a/tests/conftest.py b/tests/conftest.py index db72abf53..c80b5f22d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -476,3 +476,145 @@ def second_group(thing): yield group session.close() + + +@pytest.fixture(scope="session") +def lexicon_category(): + with session_ctx() as session: + category = LexiconCategory( + name="first test category", description="describes the first test category" + ) + session.add(category) + session.commit() + session.refresh(category) + yield category + + +@pytest.fixture(scope="function") +def second_lexicon_category(): + with session_ctx() as session: + category = LexiconCategory( + name="second test category", + description="describes the second test category", + ) + session.add(category) + session.commit() + session.refresh(category) + yield category + session.delete(category) + session.commit() + + +@pytest.fixture(scope="session") +def lexicon_term(lexicon_category): + with session_ctx() as session: + term = LexiconTerm( + term="first test term", + definition="defines the first test term", + ) + session.add(term) + session.commit() + session.refresh(term) + + term_category_association = LexiconTermCategoryAssociation( + term_id=term.id, category_id=lexicon_category.id + ) + session.add(term_category_association) + session.commit() + session.refresh(term_category_association) + + yield term + + +@pytest.fixture(scope="session") +def second_lexicon_term(lexicon_category): + with session_ctx() as session: + term = LexiconTerm( + term="second test term", + definition="defines the second test term", + ) + session.add(term) + session.commit() + session.refresh(term) + + term_category_association = LexiconTermCategoryAssociation( + term_id=term.id, category_id=lexicon_category.id + ) + session.add(term_category_association) + session.commit() + session.refresh(term_category_association) + + yield term + session.commit() + + +@pytest.fixture(scope="session") +def third_lexicon_term(lexicon_category): + with session_ctx() as session: + term = LexiconTerm( + term="third test term", + definition="defines the third test term", + ) + session.add(term) + session.commit() + session.refresh(term) + + term_category_association = LexiconTermCategoryAssociation( + term_id=term.id, category_id=lexicon_category.id + ) + session.add(term_category_association) + session.commit() + session.refresh(term_category_association) + + yield term + session.commit() + + +@pytest.fixture(scope="session") +def fourth_lexicon_term(lexicon_category): + with session_ctx() as session: + term = LexiconTerm( + term="fourth test term", + definition="defines the fourth test term", + ) + session.add(term) + session.commit() + session.refresh(term) + + term_category_association = LexiconTermCategoryAssociation( + term_id=term.id, category_id=lexicon_category.id + ) + session.add(term_category_association) + session.commit() + session.refresh(term_category_association) + + yield term + session.commit() + + +@pytest.fixture(scope="session") +def lexicon_triple(lexicon_term, second_lexicon_term): + with session_ctx() as session: + triple = LexiconTriple( + subject=lexicon_term.term, + predicate="related_to", + object_=second_lexicon_term.term, + ) + session.add(triple) + session.commit() + session.refresh(triple) + yield triple + + +@pytest.fixture(scope="session") +def second_lexicon_triple(third_lexicon_term, fourth_lexicon_term): + with session_ctx() as session: + triple = LexiconTriple( + subject=third_lexicon_term.term, + predicate="related_to", + object_=fourth_lexicon_term.term, + ) + session.add(triple) + session.commit() + session.refresh(triple) + yield triple diff --git a/tests/test_lexicon.py b/tests/test_lexicon.py index 91c152d26..e53bfca7e 100644 --- a/tests/test_lexicon.py +++ b/tests/test_lexicon.py @@ -13,8 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -from services.validation import get_category -from tests import client, override_authentication +from db import LexiconTerm, LexiconCategory, LexiconTriple +from tests import client, override_authentication, cleanup_post_test, cleanup_patch_test from core.dependencies import admin_function, viewer_function, editor_function from main import app @@ -38,69 +38,462 @@ def override_authentication_dependency_fixture(): app.dependency_overrides = {} -def test_add_lexicon_category(): - name = "Test Category" - description = "This is a test category." +# POST tests =================================================================== + +def test_add_lexicon_term_with_new_categories(): + payload = { + "term": "test_term", + "definition": "This is a test definition.", + "categories": [{"name": "test category", "description": "test lexicon terms"}], + } response = client.post( - "/lexicon/category", - json={"name": name, "description": description}, + "/lexicon/term", + json=payload, ) - assert response.status_code == 201 data = response.json() - assert data["name"] == name - assert data["description"] == description + assert "id" in data + assert "created_at" in data + assert data["term"] == payload["term"] + assert data["definition"] == payload["definition"] + assert len(data["categories"]) == 1 + assert data["categories"][0]["name"] == payload["categories"][0]["name"] + assert ( + data["categories"][0]["description"] == payload["categories"][0]["description"] + ) + cleanup_post_test(LexiconTerm, data["id"]) + cleanup_post_test(LexiconCategory, data["categories"][0]["id"]) -def test_add_lexicon_term(): - term = "test_term" - definition = "This is a test definition." - category = "Test Category" +def test_add_lexicon_term_with_existing_categories(): + payload = { + "term": "test_term_existing_categories", + "definition": "This is a test definition.", + "categories": [{"name": "unit", "description": None}], + } response = client.post( "/lexicon/term", - json={"term": term, "definition": definition, "category": category}, + json=payload, ) assert response.status_code == 201 data = response.json() - assert data["term"] == term - assert data["definition"] == definition + assert "id" in data + assert "created_at" in data + assert data["term"] == payload["term"] + assert data["definition"] == payload["definition"] + assert len(data["categories"]) == 1 + assert data["categories"][0]["name"] == payload["categories"][0]["name"] + assert ( + data["categories"][0]["description"] == payload["categories"][0]["description"] + ) + cleanup_post_test(LexiconTerm, data["id"]) -def test_get_category(): - items = get_category("casing_material") - assert isinstance(items, list) + +def test_add_lexicon_category(): + payload = {"name": "test category name", "description": "test category description"} + response = client.post("/lexicon/category", json=payload) + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert "created_at" in data + assert data["name"] == payload["name"] + assert data["description"] == payload["description"] + + cleanup_post_test(LexiconCategory, data["id"]) -def test_add_triple(): +def test_add_lexicon_triple_new_terms(): subject = { "term": "MG-030", "definition": "magdalena well", - "category": "location_identifier", + "categories": [{"name": "location_identifier"}], } predicate = "same_as" object_ = { "term": "USGS1234", "definition": "magdalena well", - "category": "location_identifier", + "categories": [{"name": "location_identifier"}], + } + payload = { + "subject": subject, + "predicate": predicate, + "object_": object_, } - response = client.post( - "/lexicon/triple/add", - json={ - "subject": subject, - "predicate": predicate, - "object_": object_, - }, - ) + response = client.post("/lexicon/triple", json=payload) assert response.status_code == 201 data = response.json() + assert "id" in data + assert "created_at" in data assert data["subject"] == subject["term"] assert data["predicate"] == predicate assert data["object_"] == object_["term"] + cleanup_post_test(LexiconTriple, data["id"]) + + response = client.get(f"/lexicon/term?term={subject['term']}") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["term"] == subject["term"] + assert data["items"][0]["definition"] == subject["definition"] + + cleanup_post_test(LexiconTerm, data["items"][0]["id"]) + + response = client.get(f"/lexicon/term?term={object_['term']}") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["items"][0]["term"] == object_["term"] + assert data["items"][0]["definition"] == object_["definition"] + + cleanup_post_test(LexiconTerm, data["items"][0]["id"]) + cleanup_post_test(LexiconCategory, data["items"][0]["categories"][0]["id"]) + + +def test_add_lexicon_triple_existing_terms(lexicon_term, second_lexicon_term): + subject = { + "term": lexicon_term.term, + "definition": lexicon_term.definition, + "categories": [ + { + "name": category.name, + "description": category.description, + } + for category in lexicon_term.categories + ], + } + predicate = "same_as" + object_ = { + "term": second_lexicon_term.term, + "definition": second_lexicon_term.definition, + "categories": [ + { + "name": category.name, + "description": category.description, + } + for category in second_lexicon_term.categories + ], + } + payload = { + "subject": subject, + "predicate": predicate, + "object_": object_, + } + + response = client.post("/lexicon/triple", json=payload) + + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert "created_at" in data + assert data["subject"] == subject["term"] + assert data["predicate"] == predicate + assert data["object_"] == object_["term"] + + cleanup_post_test(LexiconTriple, data["id"]) + + +# PATCH tests ================================================================== + + +def test_patch_term(lexicon_term): + payload = {"term": "patched term", "definition": "patched definition"} + response = client.patch(f"/lexicon/term/{lexicon_term.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["term"] == payload["term"] + assert data["definition"] == payload["definition"] + + cleanup_patch_test(LexiconTerm, payload, lexicon_term) + + +def test_patch_term_404_not_found(lexicon_term): + bad_id = 99999 + payload = {"term": "patched term", "definition": "patched definition"} + response = client.patch(f"/lexicon/term/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconTerm with ID {bad_id} not found." + + +def test_patch_category(lexicon_category): + payload = {"name": "patched name", "description": "patched description"} + response = client.patch(f"/lexicon/category/{lexicon_category.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["name"] == payload["name"] + assert data["description"] == payload["description"] + + cleanup_patch_test(LexiconCategory, payload, lexicon_category) + + +def test_patch_category_404_not_found(lexicon_category): + bad_id = 99999 + payload = {"name": "patched name", "definition": "patched definition"} + response = client.patch(f"/lexicon/category/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconCategory with ID {bad_id} not found." + + +def test_patch_triple(lexicon_triple, third_lexicon_term, fourth_lexicon_term): + payload = { + "subject": third_lexicon_term.term, + "predicate": "patched predicate", + "object_": fourth_lexicon_term.term, + } + response = client.patch(f"/lexicon/triple/{lexicon_triple.id}", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["subject"] == payload["subject"] + assert data["predicate"] == payload["predicate"] + assert data["object_"] == payload["object_"] + + cleanup_patch_test(LexiconTriple, payload, lexicon_triple) + + +def test_patch_triple_404_not_found( + lexicon_triple, third_lexicon_term, fourth_lexicon_term +): + bad_id = 99999 + payload = { + "subject": third_lexicon_term.term, + "predicate": "patched predicate", + "object_": fourth_lexicon_term.term, + } + response = client.patch(f"/lexicon/triple/{bad_id}", json=payload) + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconTriple with ID {bad_id} not found." + + +def test_patch_triple_409_bad_subject(lexicon_triple, third_lexicon_term): + bad_subject = "nonexistent subject" + payload = { + "subject": bad_subject, + "predicate": "patched predicate", + "object_": third_lexicon_term.term, + } + response = client.patch(f"/lexicon/triple/{lexicon_triple.id}", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "subject"] + assert data["detail"][0]["msg"] == f"LexiconTerm with term {bad_subject} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"subject": bad_subject} + + +def test_patch_triple_409_bad_object(lexicon_triple, third_lexicon_term): + bad_object = "nonexistent object" + payload = { + "subject": third_lexicon_term.term, + "predicate": "patched predicate", + "object_": bad_object, + } + response = client.patch(f"/lexicon/triple/{lexicon_triple.id}", json=payload) + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "object_"] + assert data["detail"][0]["msg"] == f"LexiconTerm with term {bad_object} not found." + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"object_": bad_object} + + +# GET tests ==================================================================== + + +def test_get_lexicon_terms(): + # many terms are defined in conftest.py and core/lexicon.json, so rather + # than test a specific one just ensure the responses are correct + response = client.get("lexicon/term") + assert response.status_code == 200 + data = response.json() + assert data["total"] > 0 + for term in data["items"]: + assert isinstance(term["id"], int) + assert isinstance(term["created_at"], str) + assert isinstance(term["term"], str) + assert isinstance(term["definition"], str) + assert isinstance(term["categories"], list) + + +def test_get_lexicon_term_by_id(lexicon_term): + response = client.get(f"/lexicon/term/{lexicon_term.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == lexicon_term.id + assert data["created_at"] == lexicon_term.created_at.isoformat().replace( + "+00:00", "Z" + ) + assert data["term"] == lexicon_term.term + assert data["definition"] == lexicon_term.definition + assert len(data["categories"]) == 1 + assert data["categories"][0]["id"] == lexicon_term.categories[0].id + assert data["categories"][0]["created_at"] == lexicon_term.categories[ + 0 + ].created_at.isoformat().replace("+00:00", "Z") + assert data["categories"][0]["name"] == lexicon_term.categories[0].name + assert ( + data["categories"][0]["description"] == lexicon_term.categories[0].description + ) + -# ============= EOF ============================================= +def test_get_lexicon_terms_sort_categories_branch(): + """ + Ensure the special-case branch (sort == 'categories') in GET /lexicon is exercised. + It should not apply sorting/filtering and still return a valid pagination payload. + """ + resp = client.get("/lexicon/term", params={"sort": "categories"}) + assert resp.status_code == 200 + data = resp.json() + # fastapi-pagination returns a Page-like object with these keys + assert "items" in data + assert "total" in data + + +def test_get_lexicon_term_by_id_404_not_found(lexicon_term): + bad_id = 999999 + response = client.get(f"/lexicon/term/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconTerm with ID {bad_id} not found." + + +def test_get_lexicon_categories(): + # many categories are defined in conftest.py and core/lexicon.json, so + # rather than test a specific one just ensure the responses are correct + response = client.get("/lexicon/category") + assert response.status_code == 200 + data = response.json() + assert data["total"] > 0 + for category in data["items"]: + assert isinstance(category["id"], int) + assert isinstance(category["created_at"], str) + assert isinstance(category["name"], str) + assert isinstance(category["description"], (str, type(None))) + + +def test_get_lexicon_category_by_id(lexicon_category): + response = client.get(f"/lexicon/category/{lexicon_category.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == lexicon_category.id + assert data["created_at"] == lexicon_category.created_at.isoformat().replace( + "+00:00", "Z" + ) + assert data["name"] == lexicon_category.name + assert data["description"] == lexicon_category.description + + +def test_get_lexicon_category_by_id_404_not_found(lexicon_category): + bad_id = 999999 + response = client.get(f"/lexicon/category/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconCategory with ID {bad_id} not found." + + +def test_get_lexicon_triples(lexicon_triple): + response = client.get("/lexicon/triple") + assert response.status_code == 200 + data = response.json() + assert data["total"] > 0 + assert data["items"][0]["id"] == lexicon_triple.id + assert data["items"][0][ + "created_at" + ] == lexicon_triple.created_at.isoformat().replace("+00:00", "Z") + assert data["items"][0]["subject"] == lexicon_triple.subject + assert data["items"][0]["predicate"] == lexicon_triple.predicate + assert data["items"][0]["object_"] == lexicon_triple.object_ + + +def test_get_lexicon_triple_by_id(lexicon_triple): + response = client.get(f"/lexicon/triple/{lexicon_triple.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == lexicon_triple.id + assert data["created_at"] == lexicon_triple.created_at.isoformat().replace( + "+00:00", "Z" + ) + assert data["subject"] == lexicon_triple.subject + assert data["predicate"] == lexicon_triple.predicate + assert data["object_"] == lexicon_triple.object_ + + +def test_get_lexicon_triple_by_id_404_not_found(lexicon_triple): + bad_id = 999999 + response = client.get(f"/lexicon/triple/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconTriple with ID {bad_id} not found." + + +# DELETE tests ================================================================= + + +def test_delete_lexicon_term(second_lexicon_term): + response = client.delete(f"/lexicon/term/{second_lexicon_term.id}") + assert response.status_code == 204 + + # verify the lexicon term was deleted + response = client.get(f"/lexicon/term/{second_lexicon_term.id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconTerm with ID {second_lexicon_term.id} not found." + + +def test_delete_lexicon_term_404_not_found(second_lexicon_term): + bad_id = 999999 + response = client.delete(f"/lexicon/term/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconTerm with ID {bad_id} not found." + + +def test_delete_lexicon_category(second_lexicon_category): + response = client.delete(f"/lexicon/category/{second_lexicon_category.id}") + assert response.status_code == 204 + + # verify the lexicon category was deleted + response = client.get(f"/lexicon/category/{second_lexicon_category.id}") + assert response.status_code == 404 + data = response.json() + assert ( + data["detail"] + == f"LexiconCategory with ID {second_lexicon_category.id} not found." + ) + + +def test_delete_lexicon_category_404_not_found(second_lexicon_category): + bad_id = 999999 + response = client.delete(f"/lexicon/category/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconCategory with ID {bad_id} not found." + + +def test_delete_lexicon_triple(second_lexicon_triple): + response = client.delete(f"/lexicon/triple/{second_lexicon_triple.id}") + assert response.status_code == 204 + + # verify the lexicon triple was deleted + response = client.get(f"/lexicon/triple/{second_lexicon_triple.id}") + assert response.status_code == 404 + data = response.json() + assert ( + data["detail"] == f"LexiconTriple with ID {second_lexicon_triple.id} not found." + ) + + +def test_delete_lexicon_triple_404_not_found(second_lexicon_triple): + bad_id = 999999 + response = client.delete(f"/lexicon/triple/{bad_id}") + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"LexiconTriple with ID {bad_id} not found." diff --git a/tests/test_lexicon_pagination.py b/tests/test_lexicon_pagination.py deleted file mode 100644 index f5255f4d5..000000000 --- a/tests/test_lexicon_pagination.py +++ /dev/null @@ -1,60 +0,0 @@ -# ============================================================================== -# Copyright 2025 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ============================================================================== -from tests import client, override_authentication - -from core.dependencies import admin_function, viewer_function, editor_function -from main import app - -import pytest - - -@pytest.fixture(scope="module", autouse=True) -def override_authentication_dependency_fixture(): - - app.dependency_overrides[admin_function] = override_authentication( - default={"name": "foobar", "sub": "1234567890"} - ) - app.dependency_overrides[editor_function] = override_authentication( - default={"name": "foobar", "sub": "1234567890"} - ) - app.dependency_overrides[viewer_function] = override_authentication() - - yield - - app.dependency_overrides = {} - - -def test_get_lexicon_terms_sort_categories_branch(): - """ - Ensure the special-case branch (sort == 'categories') in GET /lexicon is exercised. - It should not apply sorting/filtering and still return a valid pagination payload. - """ - resp = client.get("/lexicon/term", params={"sort": "categories"}) - assert resp.status_code == 200 - data = resp.json() - # fastapi-pagination returns a Page-like object with these keys - assert "items" in data - assert "total" in data - - -def test_get_lexicon_categories_endpoint(): - """Basic smoke test that categories endpoint returns a paginated payload.""" - resp = client.get("/lexicon/category") - assert resp.status_code == 200 - data = resp.json() - assert "items" in data - # Should have at least one category from init_lexicon and/or previous tests - assert isinstance(data["items"], list) diff --git a/transfers/__init__.py b/transfers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/transfers/transfer2.py b/transfers/transfer2.py index 7232f92b3..7cb1b6960 100644 --- a/transfers/transfer2.py +++ b/transfers/transfer2.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # =============================================================================== -import os import time import uuid from datetime import datetime