From d935db4b3e8cea7162fd17ba3fab79b0e090366b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 1 Jul 2026 16:44:48 +0200 Subject: [PATCH 1/3] Android branch testing --- android/.idea/.gitignore | 3 + android/.idea/AndroidProjectSystem.xml | 6 ++ android/.idea/compiler.xml | 6 ++ android/.idea/deploymentTargetSelector.xml | 11 ++++ android/.idea/deviceManager.xml | 13 ++++ android/.idea/migrations.xml | 10 +++ android/.idea/misc.xml | 10 +++ android/.idea/planningMode.xml | 13 ++++ android/.idea/runConfigurations.xml | 17 +++++ android/.idea/studiobot.xml | 6 ++ android/app/build.gradle | 14 ++--- .../MainActivity.java | 2 +- android/app/src/main/res/values/strings.xml | 8 +-- android/build.gradle | 2 +- android/gradle.properties | 5 ++ android/gradle/gradle-daemon-jvm.properties | 13 ++++ android/gradle/wrapper/gradle-wrapper.jar | Bin 63375 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 +- android/gradlew | 32 +++++----- android/gradlew.bat | 58 ++++++++---------- android/settings.gradle | 3 + 21 files changed, 169 insertions(+), 68 deletions(-) create mode 100644 android/.idea/.gitignore create mode 100644 android/.idea/AndroidProjectSystem.xml create mode 100644 android/.idea/compiler.xml create mode 100644 android/.idea/deploymentTargetSelector.xml create mode 100644 android/.idea/deviceManager.xml create mode 100644 android/.idea/migrations.xml create mode 100644 android/.idea/misc.xml create mode 100644 android/.idea/planningMode.xml create mode 100644 android/.idea/runConfigurations.xml create mode 100644 android/.idea/studiobot.xml rename android/app/src/main/java/net/hacecalor/{farmafinder => farmaclic}/MainActivity.java (72%) create mode 100644 android/gradle/gradle-daemon-jvm.properties diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/android/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/android/.idea/AndroidProjectSystem.xml b/android/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/android/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/android/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/android/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/deviceManager.xml b/android/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/android/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/android/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml new file mode 100644 index 0000000..74dd639 --- /dev/null +++ b/android/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/.idea/planningMode.xml b/android/.idea/planningMode.xml new file mode 100644 index 0000000..955621a --- /dev/null +++ b/android/.idea/planningMode.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/android/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/android/.idea/studiobot.xml b/android/.idea/studiobot.xml new file mode 100644 index 0000000..539e3b8 --- /dev/null +++ b/android/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 6e0543f..95af31f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) { } android { - namespace "net.hacecalor.farmafinder" + namespace "net.hacecalor.farmaclic" compileSdk rootProject.ext.compileSdkVersion defaultConfig { - applicationId "net.hacecalor.farmafinder" + applicationId "net.hacecalor.farmaclic" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 @@ -36,7 +36,7 @@ android { release { minifyEnabled true shrinkResources true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' if (keystorePropertiesFile.exists()) { signingConfig signingConfigs.release } @@ -44,14 +44,8 @@ android { } } -repositories { - flatDir{ - dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' - } -} - dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" diff --git a/android/app/src/main/java/net/hacecalor/farmafinder/MainActivity.java b/android/app/src/main/java/net/hacecalor/farmaclic/MainActivity.java similarity index 72% rename from android/app/src/main/java/net/hacecalor/farmafinder/MainActivity.java rename to android/app/src/main/java/net/hacecalor/farmaclic/MainActivity.java index d6c7d9d..d4b076c 100644 --- a/android/app/src/main/java/net/hacecalor/farmafinder/MainActivity.java +++ b/android/app/src/main/java/net/hacecalor/farmaclic/MainActivity.java @@ -1,4 +1,4 @@ -package net.hacecalor.farmafinder; +package net.hacecalor.farmaclic; import com.getcapacitor.BridgeActivity; diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4844040..fd5aa6b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ - FarmaFinder - FarmaFinder - net.hacecalor.farmafinder - net.hacecalor.farmafinder + FarmaClic + FarmaClic + net.hacecalor.farmaclic + net.hacecalor.farmaclic diff --git a/android/build.gradle b/android/build.gradle index f8f0e43..70bb56f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.android.tools.build:gradle:9.2.1' classpath 'com.google.gms:google-services:4.4.4' // NOTE: Do not place your application dependencies here; they belong diff --git a/android/gradle.properties b/android/gradle.properties index 2e87c52..2548572 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -20,3 +20,8 @@ org.gradle.jvmargs=-Xmx1536m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +android.defaults.buildfeatures.resvalues=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.newDsl=true diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..baa28d1 --- /dev/null +++ b/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 033e24c4cdf41af1ab109bc7f253b2b887023340..b1b8ef56b44f16b14dc800fa8103a6d89abb526f 100644 GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 63375 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≫L+crb^Bz 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 /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -111,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -169,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -201,16 +202,15 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/android/gradlew.bat b/android/gradlew.bat index 6689b85..8508ef6 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -13,16 +13,18 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -43,13 +45,13 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -57,36 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle b/android/settings.gradle index 3b4431d..c3c9ec6 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,3 +1,6 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} include ':app' include ':capacitor-cordova-android-plugins' project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') -- 2.52.0 From c5df0ec0b22ca374749af5cc68fd363a5794c71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 1 Jul 2026 16:46:52 +0200 Subject: [PATCH 2/3] First implementation API for PIP (Proyecto: Pharmacy Integration Platform) --- pip-platform/.env.example | 37 ++ pip-platform/.gitignore | 20 + pip-platform/Dockerfile | 18 + pip-platform/STATE.md | 65 ++++ pip-platform/alembic.ini | 37 ++ pip-platform/alembic/env.py | 41 ++ pip-platform/alembic/script.py.mako | 24 ++ pip-platform/alembic/versions/001_initial.py | 306 +++++++++++++++ pip-platform/docker-compose.runtime.yml | 114 ++++++ pip-platform/docker-compose.yml | 122 ++++++ pip-platform/entrypoint.py | 41 ++ .../monitoring/prometheus/prometheus.yml | 18 + pip-platform/pyproject.toml | 69 ++++ pip-platform/scripts/__init__.py | 0 pip-platform/src/__init__.py | 3 + pip-platform/src/api/__init__.py | 0 pip-platform/src/api/middleware/__init__.py | 0 pip-platform/src/api/middleware/audit.py | 91 +++++ pip-platform/src/api/middleware/logging.py | 36 ++ pip-platform/src/api/middleware/rate_limit.py | 53 +++ pip-platform/src/api/v1/__init__.py | 0 .../src/api/v1/dependencies/__init__.py | 0 pip-platform/src/api/v1/dependencies/auth.py | 107 ++++++ pip-platform/src/api/v1/endpoints/__init__.py | 0 .../src/api/v1/endpoints/api_clients.py | 157 ++++++++ pip-platform/src/api/v1/endpoints/audit.py | 70 ++++ pip-platform/src/api/v1/endpoints/auth.py | 116 ++++++ pip-platform/src/api/v1/endpoints/catalog.py | 106 ++++++ .../src/api/v1/endpoints/connectors.py | 280 ++++++++++++++ .../src/api/v1/endpoints/erp_systems.py | 143 +++++++ .../src/api/v1/endpoints/inventory.py | 150 ++++++++ .../src/api/v1/endpoints/medications.py | 189 ++++++++++ .../src/api/v1/endpoints/pharmacies.py | 185 ++++++++++ .../src/api/v1/endpoints/reservations.py | 149 ++++++++ .../src/api/v1/endpoints/sync_jobs.py | 199 ++++++++++ pip-platform/src/api/v1/endpoints/system.py | 77 ++++ pip-platform/src/api/v1/endpoints/users.py | 70 ++++ pip-platform/src/api/v1/router.py | 31 ++ pip-platform/src/cli.py | 117 ++++++ pip-platform/src/connectors/__init__.py | 0 pip-platform/src/connectors/base/__init__.py | 17 + .../src/connectors/base/base_connector.py | 78 ++++ .../src/connectors/farmatic/__init__.py | 3 + .../connectors/farmatic/farmatic_connector.py | 122 ++++++ pip-platform/src/connectors/mock/__init__.py | 3 + .../src/connectors/mock/mock_connector.py | 215 +++++++++++ pip-platform/src/connectors/registry.py | 105 ++++++ pip-platform/src/connectors/rest/__init__.py | 3 + .../src/connectors/rest/rest_connector.py | 103 ++++++ pip-platform/src/connectors/sdk/__init__.py | 19 + pip-platform/src/connectors/sdk/auth.py | 123 ++++++ pip-platform/src/connectors/sdk/base_sdk.py | 137 +++++++ .../src/connectors/sdk/circuit_breaker.py | 106 ++++++ pip-platform/src/connectors/sdk/context.py | 42 +++ pip-platform/src/connectors/sdk/retry.py | 87 +++++ pip-platform/src/core/__init__.py | 0 pip-platform/src/core/exceptions.py | 77 ++++ pip-platform/src/core/logging.py | 56 +++ pip-platform/src/core/metrics.py | 94 +++++ pip-platform/src/core/tracing.py | 43 +++ pip-platform/src/domain/__init__.py | 0 pip-platform/src/domain/entities/__init__.py | 51 +++ pip-platform/src/domain/entities/entities.py | 317 ++++++++++++++++ .../src/domain/interfaces/__init__.py | 35 ++ .../src/domain/interfaces/repositories.py | 249 +++++++++++++ .../src/domain/value_objects/__init__.py | 0 pip-platform/src/infrastructure/__init__.py | 0 .../src/infrastructure/cache/__init__.py | 3 + .../src/infrastructure/cache/redis_cache.py | 73 ++++ .../src/infrastructure/config/__init__.py | 0 .../src/infrastructure/config/settings.py | 58 +++ .../src/infrastructure/database/__init__.py | 9 + .../database/models/__init__.py | 41 ++ .../infrastructure/database/models/models.py | 349 ++++++++++++++++++ .../database/repositories/__init__.py | 32 ++ .../repositories/api_client_repository.py | 116 ++++++ .../repositories/audit_log_repository.py | 78 ++++ .../connector_config_repository.py | 71 ++++ .../repositories/connector_repository.py | 101 +++++ .../connector_version_repository.py | 82 ++++ .../repositories/erp_system_repository.py | 89 +++++ .../repositories/inventory_repository.py | 142 +++++++ .../medication_catalog_repository.py | 90 +++++ .../repositories/medication_repository.py | 101 +++++ .../repositories/pharmacy_repository.py | 126 +++++++ .../repositories/reservation_repository.py | 94 +++++ .../repositories/sync_job_repository.py | 95 +++++ .../repositories/sync_log_repository.py | 53 +++ .../repositories/technical_user_repository.py | 88 +++++ .../src/infrastructure/database/session.py | 40 ++ .../src/infrastructure/messaging/__init__.py | 3 + .../src/infrastructure/messaging/rabbitmq.py | 131 +++++++ pip-platform/src/main.py | 78 ++++ pip-platform/src/services/__init__.py | 0 pip-platform/src/services/audit/__init__.py | 3 + .../src/services/audit/audit_service.py | 62 ++++ pip-platform/src/services/auth/__init__.py | 27 ++ .../src/services/auth/auth_service.py | 102 +++++ pip-platform/src/services/auth/security.py | 126 +++++++ .../src/services/auth/user_service.py | 71 ++++ .../src/services/connector/__init__.py | 0 .../src/services/connector_service.py | 145 ++++++++ .../services/inventory/inventory_service.py | 210 +++++++++++ .../src/services/medication/__init__.py | 0 .../services/medication/medication_service.py | 83 +++++ .../src/services/notification/__init__.py | 0 .../src/services/pharmacy/__init__.py | 0 .../src/services/synchronization/__init__.py | 3 + .../services/synchronization/sync_service.py | 178 +++++++++ pip-platform/tests/__init__.py | 0 pip-platform/tests/conftest.py | 78 ++++ pip-platform/tests/integration/__init__.py | 0 .../tests/integration/test_api_key_auth.py | 144 ++++++++ .../tests/integration/test_rate_limiting.py | 105 ++++++ pip-platform/tests/unit/__init__.py | 0 pip-platform/tests/unit/test_auth.py | 94 +++++ .../tests/unit/test_connector_registry.py | 102 +++++ pip-platform/tests/unit/test_connector_sdk.py | 258 +++++++++++++ pip-platform/tests/unit/test_entities.py | 111 ++++++ pip-platform/tests/unit/test_exceptions.py | 66 ++++ .../tests/unit/test_mock_connector.py | 138 +++++++ 121 files changed, 9575 insertions(+) create mode 100644 pip-platform/.env.example create mode 100644 pip-platform/.gitignore create mode 100644 pip-platform/Dockerfile create mode 100644 pip-platform/STATE.md create mode 100644 pip-platform/alembic.ini create mode 100644 pip-platform/alembic/env.py create mode 100644 pip-platform/alembic/script.py.mako create mode 100644 pip-platform/alembic/versions/001_initial.py create mode 100644 pip-platform/docker-compose.runtime.yml create mode 100644 pip-platform/docker-compose.yml create mode 100644 pip-platform/entrypoint.py create mode 100644 pip-platform/monitoring/prometheus/prometheus.yml create mode 100644 pip-platform/pyproject.toml create mode 100644 pip-platform/scripts/__init__.py create mode 100644 pip-platform/src/__init__.py create mode 100644 pip-platform/src/api/__init__.py create mode 100644 pip-platform/src/api/middleware/__init__.py create mode 100644 pip-platform/src/api/middleware/audit.py create mode 100644 pip-platform/src/api/middleware/logging.py create mode 100644 pip-platform/src/api/middleware/rate_limit.py create mode 100644 pip-platform/src/api/v1/__init__.py create mode 100644 pip-platform/src/api/v1/dependencies/__init__.py create mode 100644 pip-platform/src/api/v1/dependencies/auth.py create mode 100644 pip-platform/src/api/v1/endpoints/__init__.py create mode 100644 pip-platform/src/api/v1/endpoints/api_clients.py create mode 100644 pip-platform/src/api/v1/endpoints/audit.py create mode 100644 pip-platform/src/api/v1/endpoints/auth.py create mode 100644 pip-platform/src/api/v1/endpoints/catalog.py create mode 100644 pip-platform/src/api/v1/endpoints/connectors.py create mode 100644 pip-platform/src/api/v1/endpoints/erp_systems.py create mode 100644 pip-platform/src/api/v1/endpoints/inventory.py create mode 100644 pip-platform/src/api/v1/endpoints/medications.py create mode 100644 pip-platform/src/api/v1/endpoints/pharmacies.py create mode 100644 pip-platform/src/api/v1/endpoints/reservations.py create mode 100644 pip-platform/src/api/v1/endpoints/sync_jobs.py create mode 100644 pip-platform/src/api/v1/endpoints/system.py create mode 100644 pip-platform/src/api/v1/endpoints/users.py create mode 100644 pip-platform/src/api/v1/router.py create mode 100644 pip-platform/src/cli.py create mode 100644 pip-platform/src/connectors/__init__.py create mode 100644 pip-platform/src/connectors/base/__init__.py create mode 100644 pip-platform/src/connectors/base/base_connector.py create mode 100644 pip-platform/src/connectors/farmatic/__init__.py create mode 100644 pip-platform/src/connectors/farmatic/farmatic_connector.py create mode 100644 pip-platform/src/connectors/mock/__init__.py create mode 100644 pip-platform/src/connectors/mock/mock_connector.py create mode 100644 pip-platform/src/connectors/registry.py create mode 100644 pip-platform/src/connectors/rest/__init__.py create mode 100644 pip-platform/src/connectors/rest/rest_connector.py create mode 100644 pip-platform/src/connectors/sdk/__init__.py create mode 100644 pip-platform/src/connectors/sdk/auth.py create mode 100644 pip-platform/src/connectors/sdk/base_sdk.py create mode 100644 pip-platform/src/connectors/sdk/circuit_breaker.py create mode 100644 pip-platform/src/connectors/sdk/context.py create mode 100644 pip-platform/src/connectors/sdk/retry.py create mode 100644 pip-platform/src/core/__init__.py create mode 100644 pip-platform/src/core/exceptions.py create mode 100644 pip-platform/src/core/logging.py create mode 100644 pip-platform/src/core/metrics.py create mode 100644 pip-platform/src/core/tracing.py create mode 100644 pip-platform/src/domain/__init__.py create mode 100644 pip-platform/src/domain/entities/__init__.py create mode 100644 pip-platform/src/domain/entities/entities.py create mode 100644 pip-platform/src/domain/interfaces/__init__.py create mode 100644 pip-platform/src/domain/interfaces/repositories.py create mode 100644 pip-platform/src/domain/value_objects/__init__.py create mode 100644 pip-platform/src/infrastructure/__init__.py create mode 100644 pip-platform/src/infrastructure/cache/__init__.py create mode 100644 pip-platform/src/infrastructure/cache/redis_cache.py create mode 100644 pip-platform/src/infrastructure/config/__init__.py create mode 100644 pip-platform/src/infrastructure/config/settings.py create mode 100644 pip-platform/src/infrastructure/database/__init__.py create mode 100644 pip-platform/src/infrastructure/database/models/__init__.py create mode 100644 pip-platform/src/infrastructure/database/models/models.py create mode 100644 pip-platform/src/infrastructure/database/repositories/__init__.py create mode 100644 pip-platform/src/infrastructure/database/repositories/api_client_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/audit_log_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/connector_config_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/connector_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/connector_version_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/erp_system_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/inventory_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/medication_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/reservation_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/sync_job_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/sync_log_repository.py create mode 100644 pip-platform/src/infrastructure/database/repositories/technical_user_repository.py create mode 100644 pip-platform/src/infrastructure/database/session.py create mode 100644 pip-platform/src/infrastructure/messaging/__init__.py create mode 100644 pip-platform/src/infrastructure/messaging/rabbitmq.py create mode 100644 pip-platform/src/main.py create mode 100644 pip-platform/src/services/__init__.py create mode 100644 pip-platform/src/services/audit/__init__.py create mode 100644 pip-platform/src/services/audit/audit_service.py create mode 100644 pip-platform/src/services/auth/__init__.py create mode 100644 pip-platform/src/services/auth/auth_service.py create mode 100644 pip-platform/src/services/auth/security.py create mode 100644 pip-platform/src/services/auth/user_service.py create mode 100644 pip-platform/src/services/connector/__init__.py create mode 100644 pip-platform/src/services/connector_service.py create mode 100644 pip-platform/src/services/inventory/inventory_service.py create mode 100644 pip-platform/src/services/medication/__init__.py create mode 100644 pip-platform/src/services/medication/medication_service.py create mode 100644 pip-platform/src/services/notification/__init__.py create mode 100644 pip-platform/src/services/pharmacy/__init__.py create mode 100644 pip-platform/src/services/synchronization/__init__.py create mode 100644 pip-platform/src/services/synchronization/sync_service.py create mode 100644 pip-platform/tests/__init__.py create mode 100644 pip-platform/tests/conftest.py create mode 100644 pip-platform/tests/integration/__init__.py create mode 100644 pip-platform/tests/integration/test_api_key_auth.py create mode 100644 pip-platform/tests/integration/test_rate_limiting.py create mode 100644 pip-platform/tests/unit/__init__.py create mode 100644 pip-platform/tests/unit/test_auth.py create mode 100644 pip-platform/tests/unit/test_connector_registry.py create mode 100644 pip-platform/tests/unit/test_connector_sdk.py create mode 100644 pip-platform/tests/unit/test_entities.py create mode 100644 pip-platform/tests/unit/test_exceptions.py create mode 100644 pip-platform/tests/unit/test_mock_connector.py diff --git a/pip-platform/.env.example b/pip-platform/.env.example new file mode 100644 index 0000000..dc45e17 --- /dev/null +++ b/pip-platform/.env.example @@ -0,0 +1,37 @@ +APP_NAME=PIP - Pharmacy Integration Platform +APP_VERSION=0.1.0 +DEBUG=false + +DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip +DATABASE_POOL_SIZE=20 +DATABASE_MAX_OVERFLOW=10 +DATABASE_POOL_RECYCLE=3600 + +REDIS_URL=redis://localhost:6379/0 +REDIS_CACHE_TTL=300 + +RABBITMQ_URL=amqp://pip:pip-secret@localhost:5672/pip + +JWT_SECRET_KEY=change-me-in-production +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 +JWT_ISSUER=pip-platform + +RATE_LIMIT_PER_MINUTE=60 +RATE_LIMIT_AUTH_PER_MINUTE=10 + +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_SERVICE_NAME=pip-platform +OTEL_TRACES_ENABLED=true +OTEL_METRICS_ENABLED=true + +LOG_LEVEL=INFO +LOG_JSON_FORMAT=true + +CORS_ORIGINS=["*"] +CORS_ALLOW_CREDENTIALS=true + +PG_PASSWORD=pip-secret +RABBITMQ_PASSWORD=pip-secret +GRAFANA_PASSWORD=admin diff --git a/pip-platform/.gitignore b/pip-platform/.gitignore new file mode 100644 index 0000000..ffb37c6 --- /dev/null +++ b/pip-platform/.gitignore @@ -0,0 +1,20 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.eggs/ +*.egg +.env +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.sqlite3 +.alembic/versions/*.pyc diff --git a/pip-platform/Dockerfile b/pip-platform/Dockerfile new file mode 100644 index 0000000..34a5d7f --- /dev/null +++ b/pip-platform/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim AS base + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY pyproject.toml ./ +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir . + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--loop", "uvloop", "--http", "httptools"] diff --git a/pip-platform/STATE.md b/pip-platform/STATE.md new file mode 100644 index 0000000..7752a4f --- /dev/null +++ b/pip-platform/STATE.md @@ -0,0 +1,65 @@ +# PIP Platform — State Tracker + +## Current Phase: 3 (Complete) + +### Phase 1 — Foundation ✅ +- FastAPI app skeleton, Settings, Docker Compose (Postgres, Redis, RabbitMQ) +- SQLAlchemy async models + Alembic migration `001_initial` +- Auth: JWT (access/refresh), RBAC (admin/operator/viewer), user CRUD +- Entities: Pharmacy, Connector, Medication, Inventory, Reservation, ERP System +- Repositories + Services for all entities +- API v1 endpoints for all entities +- Middleware: CORS, request logging, metrics +- Observability: OpenTelemetry traces, Prometheus metrics + +### Phase 2 — Connector SDK ✅ +- Abstract connector interface + Mock connector implementation +- Connector registry, version management, config CRUD +- Medicationcatalog + inventory sync plumbing + +### Phase 3 — Sync, Audit, API Keys, Rate Limiting ✅ +- **Sync Jobs**: Repository, service, API endpoints (create, list, get, trigger, complete, fail, cancel, logs, status filter) +- **Audit Logging**: AuditService (`log_action`, `list_logs`), AuditMiddleware (auto-logs POST/PUT/PATCH/DELETE), audit endpoint +- **API Key Auth**: Dual auth via `get_authenticated_user` — supports both Bearer JWT and X-API-Key header; `require_role` and `require_scope` use it; API Key CRUD endpoints +- **Rate Limiting**: Custom `RateLimitMiddleware` (sliding window, X-RateLimit-* headers, 429 with Retry-After); exempt paths (docs, metrics, health) +- **RabbitMQ**: Routing keys for `sync_created`, `sync_triggered`, `sync_completed`, `sync_failed`, `sync_cancelled` + +## Architecture Decisions +- Clean Architecture: domain entities → services → API endpoints → infrastructure +- Repository pattern with SQLAlchemy async +- Dual auth: JWT Bearer + X-API-Key in single `get_current_user` dependency +- Audit middleware uses separate DB session (via `async_session_factory`) to decouple from request session +- Rate limiting: custom middleware replacing slowapi (incompatible with FastAPI 0.138+ `_IncludedRouter`) +- DB migrations: Alembic `upgrade head` runs in app lifespan (not `create_all`) +- `APIKeyEntity.key_hash` is `bytes` (sha256 digest), ORM model uses `LargeBinary(64)` + +## Key Files +| File | Purpose | +|------|---------| +| `src/main.py` | App factory, lifespan (alembic upgrade), middleware chain | +| `src/api/v1/dependencies/auth.py` | Dual auth: JWT + API Key, require_role, require_scope | +| `src/api/middleware/audit.py` | Auto-audit for write endpoints | +| `src/api/middleware/rate_limit.py` | Custom sliding window rate limiter | +| `src/api/v1/endpoints/sync_jobs.py` | Sync job CRUD + lifecycle | +| `src/api/v1/endpoints/api_clients.py` | API client + key management | +| `src/api/v1/endpoints/audit.py` | Audit log query endpoint | +| `src/services/synchronization/sync_service.py` | Sync job business logic | +| `src/services/audit/audit_service.py` | Audit service | +| `src/infrastructure/messaging/rabbitmq.py` | Event publishing routing keys | +| `alembic/versions/001_initial.py` | Full schema (all tables including sync_jobs, sync_logs, audit_logs) | + +## Configuration (env) +| Variable | Default | Description | +|----------|---------|-------------| +| `RATE_LIMIT_PER_MINUTE` | 60 | Max requests per client per minute | +| `RATE_LIMIT_AUTH_PER_MINUTE` | 10 | (reserved) Auth endpoint limit | +| `JWT_SECRET_KEY` | change-me-in-production | Must be set in production | +| `API_KEY_PREFIX` | pip | (reserved) Key namespace prefix | + +## Next Phase Ideas +- Redis-backed rate limiter (for multi-instance) +- Per-client rate limits (use `api_clients.rate_limit` column) +- API Key scope enforcement on specific endpoints (currently role-only) +- Sync job execution worker (consume RabbitMQ events, run connector) +- IP allowlist enforcement for API clients +- Alembic migration 002 for any schema changes diff --git a/pip-platform/alembic.ini b/pip-platform/alembic.ini new file mode 100644 index 0000000..f1da6a9 --- /dev/null +++ b/pip-platform/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+psycopg2://pip:pip-secret@localhost:5432/pip + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/pip-platform/alembic/env.py b/pip-platform/alembic/env.py new file mode 100644 index 0000000..4d6781c --- /dev/null +++ b/pip-platform/alembic/env.py @@ -0,0 +1,41 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from src.infrastructure.config.settings import settings +from src.infrastructure.database.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/pip-platform/alembic/script.py.mako b/pip-platform/alembic/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/pip-platform/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/pip-platform/alembic/versions/001_initial.py b/pip-platform/alembic/versions/001_initial.py new file mode 100644 index 0000000..62a7dcd --- /dev/null +++ b/pip-platform/alembic/versions/001_initial.py @@ -0,0 +1,306 @@ +"""Phase 1 initial schema + +Revision ID: 001_initial +Revises: None +Create Date: 2026-06-30 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision: str = "001_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "erp_systems", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("erp_type", sa.String(50), nullable=False, index=True), + sa.Column("version", sa.String(50)), + sa.Column("base_url", sa.String(500)), + sa.Column("auth_config", JSONB), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "pharmacies", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("code", sa.String(50), unique=True, nullable=False, index=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("address", sa.String(500)), + sa.Column("city", sa.String(100)), + sa.Column("province", sa.String(100)), + sa.Column("postal_code", sa.String(20)), + sa.Column("country", sa.String(2), default="ES"), + sa.Column("latitude", sa.Float), + sa.Column("longitude", sa.Float), + sa.Column("phone", sa.String(50)), + sa.Column("email", sa.String(255)), + sa.Column("opening_hours", JSONB), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("erp_type", sa.String(50)), + sa.Column("erp_version", sa.String(50)), + sa.Column("sync_strategy", sa.String(20), default="incremental"), + sa.Column("sync_interval_minutes", sa.Integer, default=30), + sa.Column("last_sync_at", sa.DateTime(timezone=True)), + sa.Column("last_sync_status", sa.String(20)), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + op.create_index("ix_pharmacies_location", "pharmacies", ["latitude", "longitude"]) + op.create_index("ix_pharmacies_status_erp", "pharmacies", ["status", "erp_type"]) + + op.create_table( + "connectors", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("erp_system_id", UUID(as_uuid=True), sa.ForeignKey("erp_systems.id"), nullable=False, index=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("connector_type", sa.String(50), nullable=False, index=True), + sa.Column("version", sa.String(20), default="1.0.0"), + sa.Column("config", JSONB), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("last_heartbeat_at", sa.DateTime(timezone=True)), + sa.Column("last_error", sa.Text), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"), + ) + + op.add_column("pharmacies", sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=True)) + op.create_index("ix_pharmacies_connector_id", "pharmacies", ["connector_id"]) + + op.create_table( + "connector_versions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True), + sa.Column("connector_type", sa.String(50), nullable=False, index=True), + sa.Column("version", sa.String(20), nullable=False), + sa.Column("changelog", sa.Text), + sa.Column("download_url", sa.String(500)), + sa.Column("checksum", sa.String(128)), + sa.Column("is_latest", sa.Boolean, default=False, index=True), + sa.Column("is_required", sa.Boolean, default=False), + sa.Column("released_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("connector_type", "version", name="uq_connector_version"), + ) + + op.create_table( + "connector_configs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True), + sa.Column("key", sa.String(100), nullable=False), + sa.Column("value", sa.Text, nullable=False), + sa.Column("encrypted", sa.Boolean, default=False), + sa.Column("description", sa.String(500)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("connector_id", "key", name="uq_connector_config_key"), + ) + + op.create_table( + "medications", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("nregistro", sa.String(50), unique=True, nullable=False, index=True), + sa.Column("name", sa.String(500), nullable=False, index=True), + sa.Column("active_ingredient", sa.String(500)), + sa.Column("dosage", sa.String(100)), + sa.Column("form", sa.String(100)), + sa.Column("atc_code", sa.String(20), index=True), + sa.Column("prescription_required", sa.Boolean, default=False), + sa.Column("manufacturer", sa.String(255)), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "medication_catalog", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True), + sa.Column("price", sa.Float), + sa.Column("stock", sa.Integer, default=0), + sa.Column("is_available", sa.Boolean, default=False, index=True), + sa.Column("last_verified_at", sa.DateTime(timezone=True)), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"), + ) + + op.create_table( + "inventory_records", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True), + sa.Column("stock", sa.Integer, default=0), + sa.Column("reserved_stock", sa.Integer, default=0), + sa.Column("available_stock", sa.Integer, default=0), + sa.Column("min_stock", sa.Integer), + sa.Column("max_stock", sa.Integer), + sa.Column("last_updated_at", sa.DateTime(timezone=True), index=True), + sa.Column("source", sa.String(100)), + sa.Column("batch_id", sa.String(100)), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"), + ) + + op.create_table( + "inventory_history", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("inventory_record_id", UUID(as_uuid=True), sa.ForeignKey("inventory_records.id"), nullable=False, index=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True), + sa.Column("previous_stock", sa.Integer, nullable=False), + sa.Column("new_stock", sa.Integer, nullable=False), + sa.Column("delta", sa.Integer, nullable=False), + sa.Column("reason", sa.String(255)), + sa.Column("source", sa.String(100)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + ) + + op.create_table( + "technical_users", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("username", sa.String(100), unique=True, nullable=False, index=True), + sa.Column("email", sa.String(255)), + sa.Column("full_name", sa.String(255)), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column("role", sa.String(20), default="viewer", index=True), + sa.Column("is_active", sa.Boolean, default=True, index=True), + sa.Column("scopes", JSONB, default=list), + sa.Column("last_login_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "api_clients", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text), + sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True), + sa.Column("is_active", sa.Boolean, default=True, index=True), + sa.Column("scopes", JSONB, default=list), + sa.Column("rate_limit", sa.Integer), + sa.Column("allowed_ips", JSONB, default=list), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "api_keys", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("api_client_id", UUID(as_uuid=True), sa.ForeignKey("api_clients.id"), nullable=False, index=True), + sa.Column("key_prefix", sa.String(10), nullable=False, index=True), + sa.Column("key_hash", sa.LargeBinary(64), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("is_active", sa.Boolean, default=True, index=True), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_api_keys_prefix_active", "api_keys", ["key_prefix", "is_active"]) + + op.create_table( + "reservations", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True), + sa.Column("external_reference", sa.String(255)), + sa.Column("quantity", sa.Integer, nullable=False), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("expires_at", sa.DateTime(timezone=True), index=True), + sa.Column("confirmed_at", sa.DateTime(timezone=True)), + sa.Column("cancelled_at", sa.DateTime(timezone=True)), + sa.Column("idempotency_key", sa.String(255), unique=True, index=True), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + op.create_index("ix_reservations_pharmacy_status", "reservations", ["pharmacy_id", "status"]) + op.create_index("ix_reservations_expires", "reservations", ["expires_at"]) + + op.create_table( + "sync_jobs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True), + sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True), + sa.Column("strategy", sa.String(20), nullable=False), + sa.Column("status", sa.String(20), default="pending", index=True), + sa.Column("started_at", sa.DateTime(timezone=True)), + sa.Column("completed_at", sa.DateTime(timezone=True)), + sa.Column("items_total", sa.Integer), + sa.Column("items_processed", sa.Integer), + sa.Column("items_failed", sa.Integer), + sa.Column("error_message", sa.Text), + sa.Column("metadata", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + op.create_table( + "sync_logs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("sync_job_id", UUID(as_uuid=True), sa.ForeignKey("sync_jobs.id"), nullable=False, index=True), + sa.Column("level", sa.String(20), default="INFO", nullable=False), + sa.Column("message", sa.Text, nullable=False), + sa.Column("details", JSONB), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + ) + + op.create_table( + "audit_logs", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("actor_id", UUID(as_uuid=True), index=True), + sa.Column("actor_type", sa.String(50)), + sa.Column("action", sa.String(100), nullable=False, index=True), + sa.Column("resource_type", sa.String(100), nullable=False, index=True), + sa.Column("resource_id", sa.String(255), index=True), + sa.Column("old_value", JSONB), + sa.Column("new_value", JSONB), + sa.Column("ip_address", sa.String(45)), + sa.Column("user_agent", sa.String(500)), + sa.Column("request_id", sa.String(100), index=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + ) + op.create_index("ix_audit_logs_action_resource", "audit_logs", ["action", "resource_type"]) + op.create_index("ix_audit_logs_actor_created", "audit_logs", ["actor_id", "created_at"]) + + +def downgrade() -> None: + op.drop_table("audit_logs") + op.drop_table("sync_logs") + op.drop_table("sync_jobs") + op.drop_table("reservations") + op.drop_table("api_keys") + op.drop_table("api_clients") + op.drop_table("technical_users") + op.drop_table("inventory_history") + op.drop_table("inventory_records") + op.drop_table("medication_catalog") + op.drop_table("medications") + op.drop_table("connector_configs") + op.drop_table("connector_versions") + op.drop_column("pharmacies", "connector_id") + op.drop_table("connectors") + op.drop_table("pharmacies") + op.drop_table("erp_systems") diff --git a/pip-platform/docker-compose.runtime.yml b/pip-platform/docker-compose.runtime.yml new file mode 100644 index 0000000..dc7c6c4 --- /dev/null +++ b/pip-platform/docker-compose.runtime.yml @@ -0,0 +1,114 @@ +services: + pip-api: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip + REDIS_URL: redis://redis:6379/0 + RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} + NODE_ENV: production + LOG_LEVEL: INFO + LOG_JSON_FORMAT: "true" + CORS_ORIGINS: '["*"]' + OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317 + OTEL_TRACES_ENABLED: "true" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + reservations: + cpus: "0.5" + memory: 256M + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: pip + POSTGRES_USER: pip + POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pip -d pip"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + command: > + postgres + -c max_connections=200 + -c shared_buffers=256MB + -c effective_cache_size=768MB + -c work_mem=4MB + -c maintenance_work_mem=64MB + -c checkpoint_completion_target=0.9 + -c wal_buffers=16MB + -c default_statistics_target=100 + -c random_page_cost=1.1 + -c effective_io_concurrency=200 + -c max_worker_processes=8 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6380:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + command: > + redis-server + --maxmemory 256mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + + rabbitmq: + image: rabbitmq:3-management-alpine + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: pip + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret} + RABBITMQ_DEFAULT_VHOST: pip + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + postgres_data: + redis_data: + rabbitmq_data: \ No newline at end of file diff --git a/pip-platform/docker-compose.yml b/pip-platform/docker-compose.yml new file mode 100644 index 0000000..a8e17a3 --- /dev/null +++ b/pip-platform/docker-compose.yml @@ -0,0 +1,122 @@ +services: + pip-api: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip + REDIS_URL: redis://redis:6379/0 + RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} + NODE_ENV: production + LOG_LEVEL: INFO + LOG_JSON_FORMAT: "true" + CORS_ORIGINS: '["*"]' + # Re-routed to the shared Grafana Alloy collector on srv84-macos. + # host.docker.internal resolves to the Docker host gateway from + # inside the container — works because this stack runs on the same + # host as the monitoring stack. + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4317} + OTEL_TRACES_ENABLED: "true" + extra_hosts: + # `host-gateway` is the standard Docker engine alias for the bridge + # gateway (host.docker.internal is mac/Windows-native; this covers bare Linux). + - "host.docker.internal:host-gateway" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + reservations: + cpus: "0.5" + memory: 256M + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: pip + POSTGRES_USER: pip + POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pip -d pip"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + command: > + postgres + -c max_connections=200 + -c shared_buffers=256MB + -c effective_cache_size=768MB + -c work_mem=4MB + -c maintenance_work_mem=64MB + -c checkpoint_completion_target=0.9 + -c wal_buffers=16MB + -c default_statistics_target=100 + -c random_page_cost=1.1 + -c effective_io_concurrency=200 + -c max_worker_processes=8 + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6380:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + command: > + redis-server + --maxmemory 256mb + --maxmemory-policy allkeys-lru + --appendonly yes + --appendfsync everysec + + rabbitmq: + image: rabbitmq:3-management-alpine + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: pip + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret} + RABBITMQ_DEFAULT_VHOST: pip + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +volumes: + postgres_data: + redis_data: + rabbitmq_data: diff --git a/pip-platform/entrypoint.py b/pip-platform/entrypoint.py new file mode 100644 index 0000000..d252852 --- /dev/null +++ b/pip-platform/entrypoint.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +"""Run Alembic migrations before starting the app. + +This script is designed as a Docker entrypoint pre-migration step. +It runs `alembic upgrade head` and then starts uvicorn. +""" + +import subprocess +import sys +import os + + +def run_migrations(): + db_url = os.environ.get("DATABASE_URL_SYNC") or os.environ.get("DATABASE_URL", "").replace("+asyncpg", "+psycopg2", 1) + if not db_url: + print("WARNING: No DATABASE_URL found, skipping migrations") + return + + env = os.environ.copy() + env["DATABASE_URL_SYNC"] = db_url + + result = subprocess.run( + ["alembic", "-c", "alembic.ini", "upgrade", "head"], + env=env, + cwd="/app", + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"Migration failed:\n{result.stdout}\n{result.stderr}") + sys.exit(1) + print(f"Migration successful:\n{result.stdout}") + + +if __name__ == "__main__": + run_migrations() + os.execvp( + "uvicorn", + ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", + "--workers", "4", "--loop", "uvloop", "--http", "httptools"], + ) diff --git a/pip-platform/monitoring/prometheus/prometheus.yml b/pip-platform/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..2e71a64 --- /dev/null +++ b/pip-platform/monitoring/prometheus/prometheus.yml @@ -0,0 +1,18 @@ +# Reference scrape config used by the *external* Prometheus on srv84-macos. +# This stack no longer ships its own Prometheus/Grafana — see PLAN.md and +# STATE.md for the external-integration rationale. Kept as documentation. +# +# To scrape `pip-api` directly (e.g. from a local Prometheus for debugging), +# point a static target at `host.docker.internal:8000` instead of `pip-api:8000`. +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: "pip-platform" + metrics_path: /metrics + static_configs: + - targets: ["host.docker.internal:8000"] + labels: + host: srv84-macos + role: pip-platform diff --git a/pip-platform/pyproject.toml b/pip-platform/pyproject.toml new file mode 100644 index 0000000..dc9a12e --- /dev/null +++ b/pip-platform/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "pip-platform" +version = "0.1.0" +description = "Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications" +requires-python = ">=3.13" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "sqlalchemy[asyncio]>=2.0.36", + "asyncpg>=0.30.0", + "alembic>=1.14.0", + "pydantic[email]>=2.10.0", + "pydantic-settings>=2.7.0", + "python-jose[cryptography]>=3.3.0", + "passlib[bcrypt]>=1.7.4", + "bcrypt>=4.2.0", + "python-multipart>=0.0.18", + "redis[hiredis]>=5.2.0", + "aio-pika>=9.6.0", + "opentelemetry-api>=1.29.0", + "opentelemetry-sdk>=1.29.0", + "opentelemetry-instrumentation-fastapi>=0.50b0", + "opentelemetry-instrumentation-sqlalchemy>=0.50b0", + "opentelemetry-instrumentation-redis>=0.50b0", + "opentelemetry-exporter-otlp>=1.29.0", + "prometheus-client>=0.21.0", + "structlog>=24.4.0", + "httpx>=0.28.0", + "click>=8.1.0", + "psycopg2-binary>=2.9.9", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=6.0.0", + "httpx>=0.28.0", + "ruff>=0.8.0", + "mypy>=1.13.0", + "testcontainers[postgres,redis,rabbitmq]>=4.8.0", +] + +[tool.ruff] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH", "RUF"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "-v --tb=short" + +[tool.mypy] +python_version = "3.13" +strict = true +plugins = ["pydantic.mypy"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[project.scripts] +pip = "src.cli:cli" diff --git a/pip-platform/scripts/__init__.py b/pip-platform/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/__init__.py b/pip-platform/src/__init__.py new file mode 100644 index 0000000..de1660b --- /dev/null +++ b/pip-platform/src/__init__.py @@ -0,0 +1,3 @@ +from .main import app + +__all__ = ["app"] diff --git a/pip-platform/src/api/__init__.py b/pip-platform/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/middleware/__init__.py b/pip-platform/src/api/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/middleware/audit.py b/pip-platform/src/api/middleware/audit.py new file mode 100644 index 0000000..a0fe386 --- /dev/null +++ b/pip-platform/src/api/middleware/audit.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import time +import uuid +from typing import Any + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + +from src.core.logging import get_logger +from src.domain.entities import AuditLogEntity +from src.domain.interfaces import IAuditLogRepository +from src.infrastructure.database.session import async_session_factory + +logger = get_logger("audit_middleware") + +WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +IGNORED_PATHS = {"/docs", "/redoc", "/openapi.json", "/metrics", "/api/v1/system/health"} + + +class AuditMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + response = await call_next(request) + + if request.method in WRITE_METHODS and not self._should_ignore(request.url.path): + try: + await self._record_audit(request, response) + except Exception as exc: + logger.warning("audit_log_failed", extra={"error": str(exc)}) + + return response + + def _should_ignore(self, path: str) -> bool: + return any(path.startswith(p) for p in IGNORED_PATHS) + + async def _record_audit(self, request: Request, response: Response) -> None: + actor_id = None + actor_type = "anonymous" + + if hasattr(request.state, "user") and request.state.user: + user = request.state.user + try: + actor_id = uuid.UUID(user.subject) + except (ValueError, TypeError): + pass + actor_type = user.role.value if hasattr(user.role, "value") else str(user.role) + + resource_type = self._extract_resource_type(request.url.path) + action = f"{request.method.lower()}_{resource_type}" + resource_id = self._extract_resource_id(request.url.path) + ip_address = request.client.host if request.client else None + user_agent = request.headers.get("user-agent", "") + request_id = getattr(request.state, "request_id", str(uuid.uuid4())) + + async with async_session_factory() as session: + try: + from src.infrastructure.database.repositories import AuditLogRepository + repo = AuditLogRepository(session) + entity = AuditLogEntity( + actor_id=actor_id, + actor_type=actor_type, + action=action, + resource_type=resource_type, + resource_id=resource_id, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + ) + await repo.add(entity) + await session.commit() + except Exception as exc: + logger.warning("audit_db_write_failed", extra={"error": str(exc)}) + + def _extract_resource_type(self, path: str) -> str: + parts = path.strip("/").split("/") + if len(parts) >= 3: + return parts[2] + if len(parts) >= 1: + return parts[-1] + return "unknown" + + def _extract_resource_id(self, path: str) -> str | None: + parts = path.strip("/").split("/") + for part in reversed(parts[3:] if len(parts) > 3 else parts): + try: + uuid.UUID(part) + return part + except ValueError: + continue + return None diff --git a/pip-platform/src/api/middleware/logging.py b/pip-platform/src/api/middleware/logging.py new file mode 100644 index 0000000..cde8dd4 --- /dev/null +++ b/pip-platform/src/api/middleware/logging.py @@ -0,0 +1,36 @@ +import time +import uuid + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + +from src.core.logging import get_logger + +logger = get_logger("request_logging") + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) + request.state.request_id = request_id + + start = time.perf_counter() + response = await call_next(request) + duration_ms = (time.perf_counter() - start) * 1000 + + logger.info( + "request_completed", + extra={ + "request_id": request_id, + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "duration_ms": round(duration_ms, 2), + "client_ip": request.client.host if request.client else None, + "user_agent": request.headers.get("user-agent", ""), + }, + ) + + response.headers["X-Request-ID"] = request_id + return response diff --git a/pip-platform/src/api/middleware/rate_limit.py b/pip-platform/src/api/middleware/rate_limit.py new file mode 100644 index 0000000..40da0f6 --- /dev/null +++ b/pip-platform/src/api/middleware/rate_limit.py @@ -0,0 +1,53 @@ +import time + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + +from src.infrastructure.config.settings import settings + + +class RateLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, requests_per_minute: int | None = None): + super().__init__(app) + self._rpm = requests_per_minute or settings.RATE_LIMIT_PER_MINUTE + self._clients: dict[str, list[float]] = {} + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + if request.method == "OPTIONS": + return await call_next(request) + + path = request.url.path + if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"): + return await call_next(request) + + client_key = self._get_client_key(request) + now = time.monotonic() + window = self._clients.setdefault(client_key, []) + window[:] = [t for t in window if now - t < 60] + + if len(window) >= self._rpm: + return Response( + content='{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Try again later."}}', + status_code=429, + media_type="application/json", + headers={"Retry-After": "60"}, + ) + + window.append(now) + response = await call_next(request) + remaining = self._rpm - len(window) + response.headers["X-RateLimit-Limit"] = str(self._rpm) + response.headers["X-RateLimit-Remaining"] = str(max(0, remaining)) + response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60) + return response + + @staticmethod + def _get_client_key(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + if request.client: + return request.client.host + return "unknown" diff --git a/pip-platform/src/api/v1/__init__.py b/pip-platform/src/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/v1/dependencies/__init__.py b/pip-platform/src/api/v1/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/v1/dependencies/auth.py b/pip-platform/src/api/v1/dependencies/auth.py new file mode 100644 index 0000000..4c01c39 --- /dev/null +++ b/pip-platform/src/api/v1/dependencies/auth.py @@ -0,0 +1,107 @@ +import uuid + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer + +from src.core.exceptions import ForbiddenException, UnauthorizedException +from src.domain.entities import UserRole +from src.infrastructure.database.session import AsyncSession, get_db_session +from src.services.auth.auth_service import AuthService +from src.services.auth.security import RBACGuard, TokenData, decode_token, validate_token_type +from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository +from src.services.auth.security import verify_api_key + +_bearer_scheme = HTTPBearer(auto_error=False) +_api_key_scheme = APIKeyHeader(name="X-API-Key", auto_error=False) + + +async def get_auth_service(session: AsyncSession = Depends(get_db_session)) -> AuthService: + return AuthService(session) + + +async def get_api_key_user( + api_key: str | None = Depends(_api_key_scheme), + session: AsyncSession = Depends(get_db_session), +) -> TokenData | None: + if not api_key: + return None + + prefix = api_key[:8] + key_repo = APIKeyRepository(session) + key_entity = await key_repo.get_by_prefix(prefix) + if not key_entity or not key_entity.is_active: + return None + + from datetime import datetime, timezone + if key_entity.expires_at and key_entity.expires_at < datetime.now(timezone.utc): + return None + + if not verify_api_key(api_key, key_entity.key_hash): + return None + + client_repo = APIClientRepository(session) + client = await client_repo.get_by_id(key_entity.api_client_id) + if not client or not client.is_active: + return None + + await key_repo.update_last_used(key_entity.id) + + return TokenData({ + "sub": str(client.id), + "role": "api_client", + "scopes": client.scopes or [], + "iss": "pip-platform", + "type": "access", + "jti": str(key_entity.id), + }) + + +async def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), + api_key_user: TokenData | None = Depends(get_api_key_user), +) -> TokenData: + if credentials: + try: + payload = decode_token(credentials.credentials) + validate_token_type(payload, expected_type="access") + user = TokenData(payload) + request.state.user = user + return user + except UnauthorizedException: + raise + except Exception as e: + raise UnauthorizedException(f"Invalid token: {e}") from e + + if api_key_user: + request.state.user = api_key_user + return api_key_user + + raise UnauthorizedException("Authentication required. Provide Bearer token or X-API-Key header.") + + +def get_current_user_optional( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> TokenData | None: + if not credentials: + return None + try: + payload = decode_token(credentials.credentials) + validate_token_type(payload, expected_type="access") + return TokenData(payload) + except Exception: + return None + + +def require_role(role: UserRole): + def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData: + RBACGuard.require_role(current_user, role) + return current_user + return _checker + + +def require_scope(scope: str): + def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData: + RBACGuard.require_scope(current_user, scope) + return current_user + return _checker diff --git a/pip-platform/src/api/v1/endpoints/__init__.py b/pip-platform/src/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/api/v1/endpoints/api_clients.py b/pip-platform/src/api/v1/endpoints/api_clients.py new file mode 100644 index 0000000..74f7ed1 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/api_clients.py @@ -0,0 +1,157 @@ +from datetime import datetime +from uuid import UUID +import hashlib +import secrets + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.exceptions import ConflictException, NotFoundException +from src.domain.entities import APIClientEntity, APIKeyEntity +from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository +from src.infrastructure.database.session import get_db_session +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.domain.entities import UserRole +from src.services.auth.security import TokenData, generate_api_key + +router = APIRouter(prefix="/api-clients", tags=["API Clients"]) + + +class APIClientCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + description: str | None = None + scopes: list[str] = Field(default_factory=lambda: ["pharmacies:read", "medications:read", "inventory:read"]) + rate_limit: int | None = None + allowed_ips: list[str] = Field(default_factory=list) + + +class APIClientResponse(BaseModel): + id: UUID + name: str + description: str | None + owner_id: UUID | None + is_active: bool + scopes: list[str] + rate_limit: int | None + allowed_ips: list[str] + created_at: datetime | None + updated_at: datetime | None + + +class APIKeyCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + expires_at: datetime | None = None + + +class APIKeyResponse(BaseModel): + id: UUID + name: str + key_prefix: str + is_active: bool + expires_at: datetime | None + last_used_at: datetime | None + created_at: datetime | None + + +class APIKeyCreateResponse(BaseModel): + id: UUID + name: str + key: str + key_prefix: str + expires_at: datetime | None + + +class APIClientListResponse(BaseModel): + items: list[APIClientResponse] + total: int + offset: int + limit: int + + +@router.get("", response_model=APIClientListResponse) +async def list_api_clients( + is_active: bool | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = APIClientRepository(session) + entities, total = await repo.list_clients(is_active=is_active, offset=offset, limit=limit) + items = [ + APIClientResponse( + id=e.id, name=e.name, description=e.description, owner_id=e.owner_id, + is_active=e.is_active, scopes=e.scopes, rate_limit=e.rate_limit, + allowed_ips=e.allowed_ips, created_at=e.created_at, updated_at=e.updated_at, + ) + for e in entities + ] + return APIClientListResponse(items=items, total=total, offset=offset, limit=limit) + + +@router.post("", response_model=APIClientResponse, status_code=201) +async def create_api_client( + body: APIClientCreateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = APIClientRepository(session) + entity = APIClientEntity( + name=body.name, description=body.description, + owner_id=UUID(current_user.subject), is_active=True, + scopes=body.scopes, rate_limit=body.rate_limit, + allowed_ips=body.allowed_ips, + ) + created = await repo.create(entity) + return APIClientResponse( + id=created.id, name=created.name, description=created.description, + owner_id=created.owner_id, is_active=created.is_active, + scopes=created.scopes, rate_limit=created.rate_limit, + allowed_ips=created.allowed_ips, created_at=created.created_at, + updated_at=created.updated_at, + ) + + +@router.post("/{client_id}/keys", response_model=APIKeyCreateResponse, status_code=201) +async def create_api_key( + client_id: UUID, + body: APIKeyCreateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + client_repo = APIClientRepository(session) + client = await client_repo.get_by_id(client_id) + if not client: + raise NotFoundException("API Client", str(client_id)) + + raw_key, prefix, key_hash = generate_api_key() + key_repo = APIKeyRepository(session) + key_entity = APIKeyEntity( + api_client_id=client_id, key_prefix=prefix, + key_hash=key_hash, name=body.name, + is_active=True, expires_at=body.expires_at, + ) + created = await key_repo.create(key_entity) + return APIKeyCreateResponse( + id=created.id, name=created.name, key=raw_key, + key_prefix=created.key_prefix, expires_at=created.expires_at, + ) + + +@router.get("/{client_id}/keys", response_model=list[APIKeyResponse]) +async def list_api_keys( + client_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + key_repo = APIKeyRepository(session) + keys = await key_repo.list_by_client(client_id) + return [ + APIKeyResponse( + id=k.id, name=k.name, key_prefix=k.key_prefix, + is_active=k.is_active, expires_at=k.expires_at, + last_used_at=k.last_used_at, created_at=k.created_at, + ) + for k in keys + ] diff --git a/pip-platform/src/api/v1/endpoints/audit.py b/pip-platform/src/api/v1/endpoints/audit.py new file mode 100644 index 0000000..1c7b59a --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/audit.py @@ -0,0 +1,70 @@ +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.domain.entities import UserRole +from src.infrastructure.database.repositories import AuditLogRepository +from src.infrastructure.database.session import get_db_session +from src.services.auth.security import TokenData +from src.services.audit.audit_service import AuditService + +router = APIRouter(prefix="/audit", tags=["Audit"]) + + +class AuditLogResponse(BaseModel): + id: UUID + actor_id: UUID | None + actor_type: str | None + action: str + resource_type: str + resource_id: str | None + old_value: dict | None + new_value: dict | None + ip_address: str | None + user_agent: str | None + request_id: str | None + created_at: datetime | None + + +class AuditLogListResponse(BaseModel): + items: list[AuditLogResponse] + total: int + offset: int + limit: int + + +def _get_service(session: AsyncSession) -> AuditService: + return AuditService(AuditLogRepository(session)) + + +@router.get("/logs", response_model=AuditLogListResponse) +async def list_audit_logs( + actor_id: UUID | None = None, + action: str | None = None, + resource_type: str | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + entities, total = await service.list_logs( + actor_id=actor_id, action=action, resource_type=resource_type, + offset=offset, limit=limit, + ) + items = [ + AuditLogResponse( + id=e.id, actor_id=e.actor_id, actor_type=e.actor_type, + action=e.action, resource_type=e.resource_type, + resource_id=e.resource_id, old_value=e.old_value, + new_value=e.new_value, ip_address=e.ip_address, + user_agent=e.user_agent, request_id=e.request_id, + created_at=e.created_at, + ) + for e in entities + ] + return AuditLogListResponse(items=items, total=total, offset=offset, limit=limit) diff --git a/pip-platform/src/api/v1/endpoints/auth.py b/pip-platform/src/api/v1/endpoints/auth.py new file mode 100644 index 0000000..dcf58a0 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/auth.py @@ -0,0 +1,116 @@ +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import UserRole +from src.infrastructure.database.session import get_db_session +from src.services.auth.auth_service import AuthService +from src.api.v1.dependencies.auth import get_auth_service, get_current_user, require_role +from src.services.auth.security import TokenData + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +class LoginRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=100) + password: str = Field(..., min_length=8) + + +class RegisterRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=100, pattern=r"^[A-Za-z0-9_]+$") + password: str = Field(..., min_length=8) + email: EmailStr | None = None + full_name: str | None = None + role: UserRole = UserRole.VIEWER + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class UserResponse(BaseModel): + id: UUID + username: str + email: str | None + full_name: str | None + role: str + is_active: bool + scopes: list[str] + last_login_at: datetime | None + created_at: datetime | None + + +class MeResponse(BaseModel): + id: UUID + username: str + email: str | None + full_name: str | None + role: str + is_active: bool + scopes: list[str] + + +@router.post("/register", response_model=UserResponse, status_code=201) +async def register(body: RegisterRequest, service: AuthService = Depends(get_auth_service)): + user = await service.register( + username=body.username, + password=body.password, + email=body.email, + full_name=body.full_name, + role=body.role, + ) + return UserResponse( + id=user.id, + username=user.username, + email=user.email, + full_name=user.full_name, + role=user.role.value, + is_active=user.is_active, + scopes=user.scopes, + last_login_at=user.last_login_at, + created_at=user.created_at, + ) + + +@router.post("/token", response_model=TokenResponse) +async def login(body: LoginRequest, service: AuthService = Depends(get_auth_service)): + user, access_token, refresh_token = await service.authenticate(body.username, body.password) + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=30 * 60, + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token(body: RefreshRequest, service: AuthService = Depends(get_auth_service)): + access_token, refresh_token = await service.refresh_access_token(body.refresh_token) + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + expires_in=30 * 60, + ) + + +@router.get("/me", response_model=MeResponse) +async def get_me(current_user: TokenData = Depends(get_current_user)): + return MeResponse( + id=UUID(current_user.subject), + username="", + email=None, + full_name=None, + role=current_user.role.value, + is_active=True, + scopes=current_user.scopes, + ) diff --git a/pip-platform/src/api/v1/endpoints/catalog.py b/pip-platform/src/api/v1/endpoints/catalog.py new file mode 100644 index 0000000..3188b8d --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/catalog.py @@ -0,0 +1,106 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.core.exceptions import NotFoundException +from src.domain.entities import MedicationCatalogEntryEntity, UserRole +from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository +from src.infrastructure.database.session import get_db_session +from src.services.auth.security import TokenData +from src.services.medication.medication_service import MedicationService + +router = APIRouter(prefix="/pharmacies/{pharmacy_id}/catalog", tags=["Medication Catalog"]) + + +class CatalogEntryUpsertRequest(BaseModel): + medication_id: UUID + price: float | None = None + stock: int = 0 + is_available: bool = False + last_verified_at: datetime | None = None + metadata: dict[str, Any] | None = None + + +class CatalogEntryResponse(BaseModel): + id: UUID + pharmacy_id: UUID + medication_id: UUID + price: float | None + stock: int + is_available: bool + last_verified_at: datetime | None + metadata: dict[str, Any] | None + created_at: datetime | None + updated_at: datetime | None + + +class CatalogListResponse(BaseModel): + items: list[CatalogEntryResponse] + total: int + offset: int + limit: int + + +def _entity_to_response(e: MedicationCatalogEntryEntity) -> CatalogEntryResponse: + return CatalogEntryResponse( + id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id, + price=e.price, stock=e.stock, is_available=e.is_available, + last_verified_at=e.last_verified_at, metadata=e.metadata_, + created_at=e.created_at, updated_at=e.updated_at, + ) + + +def _get_service(session: AsyncSession) -> MedicationService: + return MedicationService( + medication_repo=MedicationRepository(session), + catalog_repo=MedicationCatalogRepository(session), + ) + + +@router.get("", response_model=CatalogListResponse) +async def list_catalog( + pharmacy_id: UUID, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entities, total = await service.list_catalog(pharmacy_id, offset=offset, limit=limit) + return CatalogListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/{medication_id}", response_model=CatalogEntryResponse) +async def get_catalog_entry( + pharmacy_id: UUID, + medication_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entity = await service.get_catalog_entry(pharmacy_id, medication_id) + if not entity: + raise NotFoundException("CatalogEntry", f"{pharmacy_id}/{medication_id}") + return _entity_to_response(entity) + + +@router.put("", response_model=CatalogEntryResponse) +async def upsert_catalog_entry( + pharmacy_id: UUID, + body: CatalogEntryUpsertRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + entity = MedicationCatalogEntryEntity( + pharmacy_id=pharmacy_id, medication_id=body.medication_id, + price=body.price, stock=body.stock, is_available=body.is_available, + last_verified_at=body.last_verified_at, metadata_=body.metadata, + ) + upserted = await service.upsert_catalog_entry(entity) + return _entity_to_response(upserted) diff --git a/pip-platform/src/api/v1/endpoints/connectors.py b/pip-platform/src/api/v1/endpoints/connectors.py new file mode 100644 index 0000000..b80e1fd --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/connectors.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.connectors.base import ConnectorResult +from src.core.exceptions import NotFoundException, ValidationException +from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus, ERPType, UserRole +from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository +from src.infrastructure.database.session import get_db_session +from src.services.auth.security import TokenData +from src.services.connector_service import ConnectorService + +router = APIRouter(prefix="/connectors", tags=["Connectors"]) + + +class ConnectorCreateRequest(BaseModel): + erp_system_id: UUID + pharmacy_id: UUID + name: str = Field(..., min_length=1, max_length=255) + connector_type: str + version: str = "1.0.0" + config: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None + + +class ConnectorUpdateRequest(BaseModel): + name: str | None = Field(None, min_length=1, max_length=255) + version: str | None = None + config: dict[str, Any] | None = None + status: str | None = None + metadata: dict[str, Any] | None = None + + +class ConnectorConfigRequest(BaseModel): + key: str = Field(..., min_length=1, max_length=100) + value: str + encrypted: bool = False + description: str | None = None + + +class ConnectorResponse(BaseModel): + id: UUID + erp_system_id: UUID + pharmacy_id: UUID + name: str + connector_type: str + version: str + config: dict[str, Any] | None + status: str + last_heartbeat_at: datetime | None + last_error: str | None + metadata: dict[str, Any] | None + created_at: datetime | None + updated_at: datetime | None + + +class ConnectorListResponse(BaseModel): + items: list[ConnectorResponse] + total: int + offset: int + limit: int + + +class ConnectorConfigResponse(BaseModel): + id: UUID + connector_id: UUID + key: str + value: str + encrypted: bool + description: str | None + created_at: datetime | None + updated_at: datetime | None + + +class ConnectorTestResponse(BaseModel): + success: bool + data: dict[str, Any] | None = None + error: str | None = None + + +class ConnectorDeployResponse(BaseModel): + id: UUID + status: str + message: str + + +def _entity_to_response(e: ConnectorEntity) -> ConnectorResponse: + return ConnectorResponse( + id=e.id, erp_system_id=e.erp_system_id, pharmacy_id=e.pharmacy_id, + name=e.name, connector_type=e.connector_type, version=e.version, + config=e.config, status=e.status, last_heartbeat_at=e.last_heartbeat_at, + last_error=e.last_error, metadata=e.metadata_, created_at=e.created_at, + updated_at=e.updated_at, + ) + + +def _config_entity_to_response(e: ConnectorConfigEntity) -> ConnectorConfigResponse: + return ConnectorConfigResponse( + id=e.id, connector_id=e.connector_id, key=e.key, value=e.value, + encrypted=e.encrypted, description=e.description, + created_at=e.created_at, updated_at=e.updated_at, + ) + + +def _get_service(session: AsyncSession) -> ConnectorService: + return ConnectorService( + connector_repo=ConnectorRepository(session), + config_repo=ConnectorConfigRepository(session), + ) + + +@router.get("", response_model=ConnectorListResponse) +async def list_connectors( + erp_system_id: UUID | None = None, + pharmacy_id: UUID | None = None, + status: str | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entities, total = await service.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit) + return ConnectorListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/types", response_model=list[str]) +async def list_connector_types( + current_user: TokenData = Depends(get_current_user), +): + from src.connectors.registry import connector_registry + return connector_registry.list_registered_types() + + +@router.get("/{connector_id}", response_model=ConnectorResponse) +async def get_connector( + connector_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entity = await service.get_connector(connector_id) + if not entity: + raise NotFoundException("Connector", str(connector_id)) + return _entity_to_response(entity) + + +@router.post("", response_model=ConnectorResponse, status_code=201) +async def create_connector( + body: ConnectorCreateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + entity = ConnectorEntity( + erp_system_id=body.erp_system_id, pharmacy_id=body.pharmacy_id, + name=body.name, connector_type=body.connector_type, + version=body.version, config=body.config, + status=ConnectorStatus.PENDING, metadata_=body.metadata, + ) + created = await service.create_connector(entity) + return _entity_to_response(created) + + +@router.put("/{connector_id}", response_model=ConnectorResponse) +async def update_connector( + connector_id: UUID, + body: ConnectorUpdateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + entity = await service.get_connector(connector_id) + if not entity: + raise NotFoundException("Connector", str(connector_id)) + + update_data = body.model_dump(exclude_unset=True) + if "metadata" in update_data: + entity.metadata_ = update_data.pop("metadata") + for key, value in update_data.items(): + setattr(entity, key, value) + + updated = await service.update_connector(entity) + return _entity_to_response(updated) + + +@router.delete("/{connector_id}", status_code=204) +async def delete_connector( + connector_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + deleted = await service.delete_connector(connector_id) + if not deleted: + raise NotFoundException("Connector", str(connector_id)) + + +@router.post("/{connector_id}/test", response_model=ConnectorTestResponse) +async def test_connector( + connector_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + result = await service.test_connection(connector_id) + return ConnectorTestResponse(success=result.success, data=result.data, error=result.error) + + +@router.post("/{connector_id}/deploy", response_model=ConnectorDeployResponse) +async def deploy_connector( + connector_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + entity = await service.deploy_connector(connector_id) + return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deployed successfully") + + +@router.post("/{connector_id}/deactivate", response_model=ConnectorDeployResponse) +async def deactivate_connector( + connector_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + entity = await service.deactivate_connector(connector_id) + return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deactivated successfully") + + +@router.get("/{connector_id}/configs", response_model=list[ConnectorConfigResponse]) +async def list_connector_configs( + connector_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + connector = await service.get_connector(connector_id) + if not connector: + raise NotFoundException("Connector", str(connector_id)) + configs = await service.get_configs(connector_id) + return [_config_entity_to_response(c) for c in configs] + + +@router.put("/{connector_id}/configs", response_model=ConnectorConfigResponse) +async def upsert_connector_config( + connector_id: UUID, + body: ConnectorConfigRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + connector = await service.get_connector(connector_id) + if not connector: + raise NotFoundException("Connector", str(connector_id)) + cfg_entity = ConnectorConfigEntity( + connector_id=connector_id, key=body.key, value=body.value, + encrypted=body.encrypted, description=body.description, + ) + created = await service.upsert_config(cfg_entity) + return _config_entity_to_response(created) + + +@router.delete("/{connector_id}/configs/{config_id}", status_code=204) +async def delete_connector_config( + connector_id: UUID, + config_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + await service.delete_config(config_id) diff --git a/pip-platform/src/api/v1/endpoints/erp_systems.py b/pip-platform/src/api/v1/endpoints/erp_systems.py new file mode 100644 index 0000000..012f5a6 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/erp_systems.py @@ -0,0 +1,143 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.core.exceptions import NotFoundException +from src.domain.entities import ERPSystemEntity, UserRole +from src.domain.interfaces import IERPSystemRepository +from src.infrastructure.database.repositories import ERPSystemRepository +from src.infrastructure.database.session import get_db_session +from src.services.auth.security import TokenData + +router = APIRouter(prefix="/erp-systems", tags=["ERP Systems"]) + + +class ERPSystemCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + erp_type: str + version: str | None = None + base_url: str | None = None + auth_config: dict[str, Any] | None = None + metadata: dict[str, Any] | None = None + + +class ERPSystemUpdateRequest(BaseModel): + name: str | None = Field(None, min_length=1, max_length=255) + version: str | None = None + base_url: str | None = None + auth_config: dict[str, Any] | None = None + status: str | None = None + metadata: dict[str, Any] | None = None + + +class ERPSystemResponse(BaseModel): + id: UUID + name: str + erp_type: str + version: str | None + base_url: str | None + auth_config: dict[str, Any] | None + status: str + metadata: dict[str, Any] | None + created_at: datetime | None + updated_at: datetime | None + + +class ERPSystemListResponse(BaseModel): + items: list[ERPSystemResponse] + total: int + offset: int + limit: int + + +def _entity_to_response(e: ERPSystemEntity) -> ERPSystemResponse: + return ERPSystemResponse( + id=e.id, name=e.name, erp_type=e.erp_type, version=e.version, + base_url=e.base_url, auth_config=e.auth_config, status=e.status, + metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at, + ) + + +def _get_repo(session: AsyncSession) -> IERPSystemRepository: + return ERPSystemRepository(session) + + +@router.get("", response_model=ERPSystemListResponse) +async def list_erp_systems( + erp_type: str | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + repo = _get_repo(session) + entities, total = await repo.list_erp_systems(erp_type=erp_type, offset=offset, limit=limit) + return ERPSystemListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/{erp_id}", response_model=ERPSystemResponse) +async def get_erp_system( + erp_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + repo = _get_repo(session) + entity = await repo.get_by_id(erp_id) + if not entity: + raise NotFoundException("ERP System", str(erp_id)) + return _entity_to_response(entity) + + +@router.post("", response_model=ERPSystemResponse, status_code=201) +async def create_erp_system( + body: ERPSystemCreateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = _get_repo(session) + entity = ERPSystemEntity( + name=body.name, erp_type=body.erp_type, version=body.version, + base_url=body.base_url, auth_config=body.auth_config, + metadata_=body.metadata, + ) + created = await repo.create(entity) + return _entity_to_response(created) + + +@router.put("/{erp_id}", response_model=ERPSystemResponse) +async def update_erp_system( + erp_id: UUID, + body: ERPSystemUpdateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = _get_repo(session) + entity = await repo.get_by_id(erp_id) + if not entity: + raise NotFoundException("ERP System", str(erp_id)) + + update_data = body.model_dump(exclude_unset=True) + if "metadata" in update_data: + entity.metadata_ = update_data.pop("metadata") + for key, value in update_data.items(): + setattr(entity, key, value) + + updated = await repo.update(entity) + return _entity_to_response(updated) + + +@router.delete("/{erp_id}", status_code=204) +async def delete_erp_system( + erp_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = _get_repo(session) + deleted = await repo.delete(erp_id) + if not deleted: + raise NotFoundException("ERP System", str(erp_id)) diff --git a/pip-platform/src/api/v1/endpoints/inventory.py b/pip-platform/src/api/v1/endpoints/inventory.py new file mode 100644 index 0000000..9181f3a --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/inventory.py @@ -0,0 +1,150 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.core.exceptions import NotFoundException +from src.domain.entities import InventoryRecordEntity, UserRole +from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository +from src.infrastructure.database.session import get_db_session +from src.services.auth.security import TokenData +from src.services.inventory.inventory_service import InventoryService + +router = APIRouter(prefix="/pharmacies/{pharmacy_id}/inventory", tags=["Inventory"]) + + +class InventoryUpsertRequest(BaseModel): + medication_id: UUID + stock: int = Field(..., ge=0) + reserved_stock: int = Field(0, ge=0) + available_stock: int = Field(0, ge=0) + min_stock: int | None = Field(None, ge=0) + max_stock: int | None = Field(None, ge=0) + last_updated_at: datetime | None = None + source: str | None = Field(None, max_length=100) + batch_id: str | None = Field(None, max_length=100) + metadata: dict[str, Any] | None = None + + +class InventoryRecordResponse(BaseModel): + id: UUID + pharmacy_id: UUID + medication_id: UUID + stock: int + reserved_stock: int + available_stock: int + min_stock: int | None + max_stock: int | None + last_updated_at: datetime | None + source: str | None + batch_id: str | None + metadata: dict[str, Any] | None + created_at: datetime | None + updated_at: datetime | None + + +class InventoryListResponse(BaseModel): + items: list[InventoryRecordResponse] + total: int + offset: int + limit: int + + +class InventoryHistoryResponse(BaseModel): + id: UUID + pharmacy_id: UUID + medication_id: UUID + previous_stock: int + new_stock: int + delta: int + reason: str | None + source: str | None + created_at: datetime | None + + +def _record_entity_to_response(e: InventoryRecordEntity) -> InventoryRecordResponse: + return InventoryRecordResponse( + id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id, + stock=e.stock, reserved_stock=e.reserved_stock, available_stock=e.available_stock, + min_stock=e.min_stock, max_stock=e.max_stock, + last_updated_at=e.last_updated_at, source=e.source, batch_id=e.batch_id, + metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at, + ) + + +def _get_service(session: AsyncSession) -> InventoryService: + return InventoryService( + inventory_repo=InventoryRepository(session), + reservation_repo=ReservationRepository(session), + ) + + +@router.get("", response_model=InventoryListResponse) +async def list_inventory( + pharmacy_id: UUID, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entities, total = await service.list_inventory(pharmacy_id, offset=offset, limit=limit) + return InventoryListResponse(items=[_record_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/{medication_id}", response_model=InventoryRecordResponse) +async def get_inventory_record( + pharmacy_id: UUID, + medication_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entity = await service.get_inventory_record(pharmacy_id, medication_id) + if not entity: + raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}") + return _record_entity_to_response(entity) + + +@router.put("", response_model=InventoryRecordResponse) +async def upsert_inventory_record( + pharmacy_id: UUID, + body: InventoryUpsertRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + entity = InventoryRecordEntity( + pharmacy_id=pharmacy_id, medication_id=body.medication_id, + stock=body.stock, reserved_stock=body.reserved_stock, + available_stock=body.available_stock, min_stock=body.min_stock, + max_stock=body.max_stock, last_updated_at=body.last_updated_at, + source=body.source, batch_id=body.batch_id, metadata_=body.metadata, + ) + upserted = await service.upsert_inventory_record(entity) + return _record_entity_to_response(upserted) + + +@router.get("/{medication_id}/history", response_model=list[InventoryHistoryResponse]) +async def get_inventory_history( + pharmacy_id: UUID, + medication_id: UUID, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + history = await service.get_inventory_history(pharmacy_id, medication_id, offset=offset, limit=limit) + return [ + InventoryHistoryResponse( + id=h.id, pharmacy_id=h.pharmacy_id, medication_id=h.medication_id, + previous_stock=h.previous_stock, new_stock=h.new_stock, delta=h.delta, + reason=h.reason, source=h.source, created_at=h.created_at, + ) + for h in history + ] diff --git a/pip-platform/src/api/v1/endpoints/medications.py b/pip-platform/src/api/v1/endpoints/medications.py new file mode 100644 index 0000000..156d3f6 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/medications.py @@ -0,0 +1,189 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.core.exceptions import ConflictException, NotFoundException +from src.domain.entities import MedicationEntity, UserRole +from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository +from src.infrastructure.database.session import get_db_session +from src.services.auth.security import TokenData +from src.services.medication.medication_service import MedicationService + +router = APIRouter(prefix="/medications", tags=["Medications"]) + + +class MedicationCreateRequest(BaseModel): + nregistro: str = Field(..., min_length=1, max_length=50) + name: str = Field(..., min_length=1, max_length=500) + active_ingredient: str | None = Field(None, max_length=500) + dosage: str | None = Field(None, max_length=100) + form: str | None = Field(None, max_length=100) + atc_code: str | None = Field(None, max_length=20) + prescription_required: bool = False + manufacturer: str | None = Field(None, max_length=255) + metadata: dict[str, Any] | None = None + + +class MedicationUpdateRequest(BaseModel): + name: str | None = Field(None, min_length=1, max_length=500) + active_ingredient: str | None = Field(None, max_length=500) + dosage: str | None = Field(None, max_length=100) + form: str | None = Field(None, max_length=100) + atc_code: str | None = Field(None, max_length=20) + prescription_required: bool | None = None + manufacturer: str | None = Field(None, max_length=255) + metadata: dict[str, Any] | None = None + + +class MedicationResponse(BaseModel): + id: UUID + nregistro: str + name: str + active_ingredient: str | None + dosage: str | None + form: str | None + atc_code: str | None + prescription_required: bool + manufacturer: str | None + metadata: dict[str, Any] | None + created_at: datetime | None + updated_at: datetime | None + + +class MedicationListResponse(BaseModel): + items: list[MedicationResponse] + total: int + offset: int + limit: int + + +def _entity_to_response(e: MedicationEntity) -> MedicationResponse: + return MedicationResponse( + id=e.id, nregistro=e.nregistro, name=e.name, + active_ingredient=e.active_ingredient, dosage=e.dosage, + form=e.form, atc_code=e.atc_code, + prescription_required=e.prescription_required, + manufacturer=e.manufacturer, metadata=e.metadata_, + created_at=e.created_at, updated_at=e.updated_at, + ) + + +def _get_service(session: AsyncSession) -> MedicationService: + return MedicationService( + medication_repo=MedicationRepository(session), + catalog_repo=MedicationCatalogRepository(session), + ) + + +@router.get("", response_model=MedicationListResponse) +async def list_medications( + q: str | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + if q: + entities, total = await service.search_medications(q, offset=offset, limit=limit) + else: + from src.domain.entities import MedicationEntity + all_entities, total = await service.search_medications("", offset=offset, limit=limit) + entities = all_entities + return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/search", response_model=MedicationListResponse) +async def search_medications( + q: str = Query(..., min_length=1), + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entities, total = await service.search_medications(q, offset=offset, limit=limit) + return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/nregistro/{nregistro}", response_model=MedicationResponse) +async def get_medication_by_nregistro( + nregistro: str, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entity = await service.get_by_nregistro(nregistro) + if not entity: + raise NotFoundException("Medication", nregistro) + return _entity_to_response(entity) + + +@router.get("/{medication_id}", response_model=MedicationResponse) +async def get_medication( + medication_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entity = await service.get_medication(medication_id) + if not entity: + raise NotFoundException("Medication", str(medication_id)) + return _entity_to_response(entity) + + +@router.post("", response_model=MedicationResponse, status_code=201) +async def create_medication( + body: MedicationCreateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + entity = MedicationEntity( + nregistro=body.nregistro, name=body.name, + active_ingredient=body.active_ingredient, dosage=body.dosage, + form=body.form, atc_code=body.atc_code, + prescription_required=body.prescription_required, + manufacturer=body.manufacturer, metadata_=body.metadata, + ) + created = await service.create_medication(entity) + return _entity_to_response(created) + + +@router.put("/{medication_id}", response_model=MedicationResponse) +async def update_medication( + medication_id: UUID, + body: MedicationUpdateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + entity = await service.get_medication(medication_id) + if not entity: + raise NotFoundException("Medication", str(medication_id)) + + update_data = body.model_dump(exclude_unset=True) + if "metadata" in update_data: + entity.metadata_ = update_data.pop("metadata") + for key, value in update_data.items(): + setattr(entity, key, value) + + updated = await service.update_medication(entity) + return _entity_to_response(updated) + + +@router.delete("/{medication_id}", status_code=204) +async def delete_medication( + medication_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + service = _get_service(session) + deleted = await service.delete_medication(medication_id) + if not deleted: + raise NotFoundException("Medication", str(medication_id)) diff --git a/pip-platform/src/api/v1/endpoints/pharmacies.py b/pip-platform/src/api/v1/endpoints/pharmacies.py new file mode 100644 index 0000000..305a338 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/pharmacies.py @@ -0,0 +1,185 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.exceptions import NotFoundException +from src.domain.entities import PharmacyEntity, PharmacyStatus +from src.domain.interfaces import IPharmacyRepository +from src.infrastructure.database.session import get_db_session +from src.infrastructure.database.repositories import PharmacyRepository +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.domain.entities import UserRole +from src.services.auth.security import TokenData + +router = APIRouter(prefix="/pharmacies", tags=["Pharmacies"]) + + +class PharmacyCreateRequest(BaseModel): + code: str = Field(..., min_length=1, max_length=50) + name: str = Field(..., min_length=1, max_length=255) + address: str | None = None + city: str | None = None + province: str | None = None + postal_code: str | None = None + country: str = "ES" + latitude: float | None = None + longitude: float | None = None + phone: str | None = None + email: str | None = None + opening_hours: dict | None = None + erp_type: str | None = None + erp_version: str | None = None + sync_strategy: str = "incremental" + sync_interval_minutes: int = 30 + + +class PharmacyUpdateRequest(BaseModel): + name: str | None = None + address: str | None = None + city: str | None = None + province: str | None = None + postal_code: str | None = None + country: str | None = None + latitude: float | None = None + longitude: float | None = None + phone: str | None = None + email: str | None = None + opening_hours: dict | None = None + status: str | None = None + erp_type: str | None = None + erp_version: str | None = None + sync_strategy: str | None = None + sync_interval_minutes: int | None = None + + +class PharmacyResponse(BaseModel): + id: UUID + code: str + name: str + address: str | None + city: str | None + province: str | None + postal_code: str | None + country: str + latitude: float | None + longitude: float | None + phone: str | None + email: str | None + opening_hours: dict | None + status: str + erp_type: str | None + erp_version: str | None + connector_id: UUID | None + sync_strategy: str + sync_interval_minutes: int + last_sync_at: datetime | None + last_sync_status: str | None + created_at: datetime | None + updated_at: datetime | None + + +class PharmacyListResponse(BaseModel): + items: list[PharmacyResponse] + total: int + offset: int + limit: int + + +def _entity_to_response(e: PharmacyEntity) -> PharmacyResponse: + return PharmacyResponse( + id=e.id, code=e.code, name=e.name, address=e.address, city=e.city, + province=e.province, postal_code=e.postal_code, country=e.country, + latitude=e.latitude, longitude=e.longitude, phone=e.phone, email=e.email, + opening_hours=e.opening_hours, status=e.status, erp_type=e.erp_type, + erp_version=e.erp_version, connector_id=e.connector_id, + sync_strategy=e.sync_strategy, sync_interval_minutes=e.sync_interval_minutes, + last_sync_at=e.last_sync_at, last_sync_status=e.last_sync_status, + created_at=e.created_at, updated_at=e.updated_at, + ) + + +def _get_repo(session: AsyncSession) -> IPharmacyRepository: + return PharmacyRepository(session) + + +@router.get("", response_model=PharmacyListResponse) +async def list_pharmacies( + status: str | None = None, + erp_type: str | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + repo = _get_repo(session) + entities, total = await repo.list_pharmacies(status=status, erp_type=erp_type, offset=offset, limit=limit) + return PharmacyListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/{pharmacy_id}", response_model=PharmacyResponse) +async def get_pharmacy( + pharmacy_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + repo = _get_repo(session) + entity = await repo.get_by_id(pharmacy_id) + if not entity: + raise NotFoundException("Pharmacy", str(pharmacy_id)) + return _entity_to_response(entity) + + +@router.post("", response_model=PharmacyResponse, status_code=201) +async def create_pharmacy( + body: PharmacyCreateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = _get_repo(session) + entity = PharmacyEntity( + code=body.code, name=body.name, address=body.address, city=body.city, + province=body.province, postal_code=body.postal_code, country=body.country, + latitude=body.latitude, longitude=body.longitude, phone=body.phone, + email=body.email, opening_hours=body.opening_hours, + status=PharmacyStatus.ACTIVE.value, erp_type=body.erp_type, + erp_version=body.erp_version, sync_strategy=body.sync_strategy, + sync_interval_minutes=body.sync_interval_minutes, + ) + created = await repo.create(entity) + return _entity_to_response(created) + + +@router.put("/{pharmacy_id}", response_model=PharmacyResponse) +async def update_pharmacy( + pharmacy_id: UUID, + body: PharmacyUpdateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = _get_repo(session) + entity = await repo.get_by_id(pharmacy_id) + if not entity: + raise NotFoundException("Pharmacy", str(pharmacy_id)) + + update_data = body.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(entity, key, value) + + updated = await repo.update(entity) + return _entity_to_response(updated) + + +@router.delete("/{pharmacy_id}", status_code=204) +async def delete_pharmacy( + pharmacy_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = _get_repo(session) + deleted = await repo.delete(pharmacy_id) + if not deleted: + raise NotFoundException("Pharmacy", str(pharmacy_id)) diff --git a/pip-platform/src/api/v1/endpoints/reservations.py b/pip-platform/src/api/v1/endpoints/reservations.py new file mode 100644 index 0000000..140672a --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/reservations.py @@ -0,0 +1,149 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.core.exceptions import NotFoundException, ValidationException +from src.domain.entities import ReservationEntity, ReservationStatus, UserRole +from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository +from src.infrastructure.database.session import get_db_session +from src.services.auth.security import TokenData +from src.services.inventory.inventory_service import InventoryService + +router = APIRouter(prefix="/pharmacies/{pharmacy_id}/reservations", tags=["Reservations"]) + + +class ReservationCreateRequest(BaseModel): + medication_id: UUID + quantity: int = Field(..., gt=0) + external_reference: str | None = Field(None, max_length=255) + idempotency_key: str | None = Field(None, max_length=255) + ttl_minutes: int = Field(30, ge=1, le=1440) + metadata: dict[str, Any] | None = None + + +class ReservationResponse(BaseModel): + id: UUID + pharmacy_id: UUID + medication_id: UUID + user_id: UUID | None + external_reference: str | None + quantity: int + status: str + expires_at: datetime | None + confirmed_at: datetime | None + cancelled_at: datetime | None + idempotency_key: str | None + metadata: dict[str, Any] | None + created_at: datetime | None + updated_at: datetime | None + + +class ReservationListResponse(BaseModel): + items: list[ReservationResponse] + total: int + offset: int + limit: int + + +def _entity_to_response(e: ReservationEntity) -> ReservationResponse: + return ReservationResponse( + id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id, + user_id=e.user_id, external_reference=e.external_reference, + quantity=e.quantity, status=e.status, expires_at=e.expires_at, + confirmed_at=e.confirmed_at, cancelled_at=e.cancelled_at, + idempotency_key=e.idempotency_key, metadata=e.metadata_, + created_at=e.created_at, updated_at=e.updated_at, + ) + + +def _get_service(session: AsyncSession) -> InventoryService: + return InventoryService( + inventory_repo=InventoryRepository(session), + reservation_repo=ReservationRepository(session), + ) + + +@router.get("", response_model=ReservationListResponse) +async def list_reservations( + pharmacy_id: UUID, + status: str | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entities, total = await service.list_reservations(pharmacy_id, status=status, offset=offset, limit=limit) + return ReservationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/{reservation_id}", response_model=ReservationResponse) +async def get_reservation( + pharmacy_id: UUID, + reservation_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entity = await service.get_reservation(reservation_id) + if not entity or entity.pharmacy_id != pharmacy_id: + raise NotFoundException("Reservation", str(reservation_id)) + return _entity_to_response(entity) + + +@router.post("", response_model=ReservationResponse, status_code=201) +async def create_reservation( + pharmacy_id: UUID, + body: ReservationCreateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + user_id = None + if hasattr(current_user, "user_id"): + user_id = current_user.user_id + elif hasattr(current_user, "sub"): + try: + user_id = UUID(current_user.sub) + except (ValueError, TypeError): + pass + + created = await service.create_reservation( + pharmacy_id=pharmacy_id, + medication_id=body.medication_id, + quantity=body.quantity, + user_id=user_id, + external_reference=body.external_reference, + idempotency_key=body.idempotency_key, + ttl_minutes=body.ttl_minutes, + ) + return _entity_to_response(created) + + +@router.post("/{reservation_id}/confirm", response_model=ReservationResponse) +async def confirm_reservation( + pharmacy_id: UUID, + reservation_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + updated = await service.confirm_reservation(reservation_id) + return _entity_to_response(updated) + + +@router.post("/{reservation_id}/cancel", response_model=ReservationResponse) +async def cancel_reservation( + pharmacy_id: UUID, + reservation_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + updated = await service.cancel_reservation(reservation_id) + return _entity_to_response(updated) diff --git a/pip-platform/src/api/v1/endpoints/sync_jobs.py b/pip-platform/src/api/v1/endpoints/sync_jobs.py new file mode 100644 index 0000000..1d7f54e --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/sync_jobs.py @@ -0,0 +1,199 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.core.exceptions import NotFoundException +from src.domain.entities import SyncJobStatus, SyncStrategy, UserRole +from src.infrastructure.database.repositories import SyncJobRepository, SyncLogRepository +from src.infrastructure.database.session import get_db_session +from src.services.auth.security import TokenData +from src.services.synchronization.sync_service import SyncService + +router = APIRouter(prefix="/pharmacies/{pharmacy_id}/sync-jobs", tags=["Sync Jobs"]) + + +class SyncJobCreateRequest(BaseModel): + connector_id: UUID + strategy: SyncStrategy = SyncStrategy.INCREMENTAL + + +class SyncJobResponse(BaseModel): + id: UUID + pharmacy_id: UUID + connector_id: UUID + strategy: str + status: str + started_at: datetime | None + completed_at: datetime | None + items_total: int | None + items_processed: int | None + items_failed: int | None + error_message: str | None + metadata: dict[str, Any] | None + created_at: datetime | None + updated_at: datetime | None + + +class SyncJobListResponse(BaseModel): + items: list[SyncJobResponse] + total: int + offset: int + limit: int + + +class SyncLogResponse(BaseModel): + id: UUID + sync_job_id: UUID + level: str + message: str + details: dict[str, Any] | None + created_at: datetime | None + + +def _entity_to_response(e: Any) -> SyncJobResponse: + return SyncJobResponse( + id=e.id, pharmacy_id=e.pharmacy_id, connector_id=e.connector_id, + strategy=e.strategy, status=e.status, started_at=e.started_at, + completed_at=e.completed_at, items_total=e.items_total, + items_processed=e.items_processed, items_failed=e.items_failed, + error_message=e.error_message, metadata=e.metadata_, + created_at=e.created_at, updated_at=e.updated_at, + ) + + +def _get_service(session: AsyncSession) -> SyncService: + return SyncService( + sync_job_repo=SyncJobRepository(session), + sync_log_repo=SyncLogRepository(session), + ) + + +@router.get("", response_model=SyncJobListResponse) +async def list_sync_jobs( + pharmacy_id: UUID, + status: str | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entities, total = await service.list_sync_jobs(pharmacy_id, status=status, offset=offset, limit=limit) + return SyncJobListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/{job_id}", response_model=SyncJobResponse) +async def get_sync_job( + pharmacy_id: UUID, + job_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + entity = await service.get_sync_job(job_id) + if not entity or entity.pharmacy_id != pharmacy_id: + raise NotFoundException("SyncJob", str(job_id)) + return _entity_to_response(entity) + + +@router.post("", response_model=SyncJobResponse, status_code=201) +async def create_sync_job( + pharmacy_id: UUID, + body: SyncJobCreateRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + created = await service.create_sync_job( + pharmacy_id=pharmacy_id, + connector_id=body.connector_id, + strategy=body.strategy, + ) + return _entity_to_response(created) + + +@router.post("/{job_id}/trigger", response_model=SyncJobResponse) +async def trigger_sync( + pharmacy_id: UUID, + job_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + updated = await service.trigger_sync(job_id) + return _entity_to_response(updated) + + +@router.post("/{job_id}/cancel", response_model=SyncJobResponse) +async def cancel_sync( + pharmacy_id: UUID, + job_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + updated = await service.cancel_sync(job_id) + return _entity_to_response(updated) + + +class SyncCompleteRequest(BaseModel): + items_total: int | None = None + items_processed: int | None = None + items_failed: int | None = None + duration_ms: int = 0 + + +class SyncFailRequest(BaseModel): + error_message: str + + +@router.post("/{job_id}/complete", response_model=SyncJobResponse) +async def complete_sync( + pharmacy_id: UUID, + job_id: UUID, + body: SyncCompleteRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + updated = await service.complete_sync(job_id, items_total=body.items_total, items_processed=body.items_processed, items_failed=body.items_failed) + return _entity_to_response(updated) + + +@router.post("/{job_id}/fail", response_model=SyncJobResponse) +async def fail_sync( + pharmacy_id: UUID, + job_id: UUID, + body: SyncFailRequest, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.OPERATOR)), +): + service = _get_service(session) + updated = await service.fail_sync(job_id, error_message=body.error_message) + return _entity_to_response(updated) + + +@router.get("/{job_id}/logs", response_model=list[SyncLogResponse]) +async def get_sync_logs( + pharmacy_id: UUID, + job_id: UUID, + level: str | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(get_current_user), +): + service = _get_service(session) + logs = await service.get_sync_logs(job_id, level=level, offset=offset, limit=limit) + return [ + SyncLogResponse( + id=l.id, sync_job_id=l.sync_job_id, level=l.level, + message=l.message, details=l.details, created_at=l.created_at, + ) + for l in logs + ] diff --git a/pip-platform/src/api/v1/endpoints/system.py b/pip-platform/src/api/v1/endpoints/system.py new file mode 100644 index 0000000..e810501 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/system.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text + +from src.infrastructure.cache.redis_cache import redis_cache +from src.infrastructure.database.session import get_db_session +from src.infrastructure.messaging.rabbitmq import rabbitmq_client +from src.api.v1.dependencies.auth import get_current_user +from src.services.auth.security import TokenData + +router = APIRouter(prefix="/system", tags=["System"]) + + +class HealthStatus(BaseModel): + status: str + version: str + services: dict[str, str] + + +class ReadinessStatus(BaseModel): + status: str + checks: dict[str, bool] + + +@router.get("/health", response_model=HealthStatus) +async def health_check(): + return HealthStatus( + status="healthy", + version="0.1.0", + services={ + "api": "healthy", + }, + ) + + +@router.get("/ready", response_model=ReadinessStatus) +async def readiness_check(db: AsyncSession = Depends(get_db_session)): + checks: dict[str, bool] = {} + + try: + # Check database connection + await db.execute(text("SELECT 1")) + db_ok = True + except Exception: + db_ok = False + checks["database"] = db_ok + + redis_ok = await redis_cache.health_check() + checks["redis"] = redis_ok + + mq_ok = await rabbitmq_client.health_check() + checks["rabbitmq"] = mq_ok + + overall = all(checks.values()) + return ReadinessStatus( + status="ready" if overall else "degraded", + checks=checks, + ) + + +@router.get("/status") +async def system_status(current_user: TokenData = Depends(get_current_user)): + return { + "platform": "Pharmacy Integration Platform", + "version": "0.1.0", + "environment": "production", + "services": { + "auth": "active", + "pharmacy": "active", + "medication": "active", + "connector": "active", + "synchronization": "active", + "audit": "active", + "notification": "active", + }, + } diff --git a/pip-platform/src/api/v1/endpoints/users.py b/pip-platform/src/api/v1/endpoints/users.py new file mode 100644 index 0000000..6dcea42 --- /dev/null +++ b/pip-platform/src/api/v1/endpoints/users.py @@ -0,0 +1,70 @@ +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.exceptions import NotFoundException +from src.domain.entities import TechnicalUserEntity, UserRole +from src.domain.interfaces import ITechnicalUserRepository +from src.infrastructure.database.repositories import TechnicalUserRepository +from src.infrastructure.database.session import get_db_session +from src.api.v1.dependencies.auth import get_current_user, require_role +from src.services.auth.security import TokenData + +router = APIRouter(prefix="/users", tags=["Technical Users"]) + + +class UserResponse(BaseModel): + id: UUID + username: str + email: str | None + full_name: str | None + role: str + is_active: bool + scopes: list[str] + last_login_at: datetime | None + created_at: datetime | None + + +class UserListResponse(BaseModel): + items: list[UserResponse] + total: int + offset: int + limit: int + + +def _entity_to_response(e: TechnicalUserEntity) -> UserResponse: + return UserResponse( + id=e.id, username=e.username, email=e.email, full_name=e.full_name, + role=e.role, is_active=e.is_active, scopes=e.scopes, + last_login_at=e.last_login_at, created_at=e.created_at, + ) + + +@router.get("", response_model=UserListResponse) +async def list_users( + role: str | None = None, + is_active: bool | None = None, + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = TechnicalUserRepository(session) + entities, total = await repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit) + return UserListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit) + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: UUID, + session: AsyncSession = Depends(get_db_session), + current_user: TokenData = Depends(require_role(UserRole.ADMIN)), +): + repo = TechnicalUserRepository(session) + entity = await repo.get_by_id(user_id) + if not entity: + raise NotFoundException("User", str(user_id)) + return _entity_to_response(entity) diff --git a/pip-platform/src/api/v1/router.py b/pip-platform/src/api/v1/router.py new file mode 100644 index 0000000..d7e20db --- /dev/null +++ b/pip-platform/src/api/v1/router.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter + +from src.api.v1.endpoints.auth import router as auth_router +from src.api.v1.endpoints.pharmacies import router as pharmacies_router +from src.api.v1.endpoints.users import router as users_router +from src.api.v1.endpoints.api_clients import router as api_clients_router +from src.api.v1.endpoints.audit import router as audit_router +from src.api.v1.endpoints.system import router as system_router +from src.api.v1.endpoints.connectors import router as connectors_router +from src.api.v1.endpoints.erp_systems import router as erp_systems_router +from src.api.v1.endpoints.medications import router as medications_router +from src.api.v1.endpoints.catalog import router as catalog_router +from src.api.v1.endpoints.inventory import router as inventory_router +from src.api.v1.endpoints.reservations import router as reservations_router +from src.api.v1.endpoints.sync_jobs import router as sync_jobs_router + +v1_router = APIRouter(prefix="/api/v1") + +v1_router.include_router(auth_router) +v1_router.include_router(pharmacies_router) +v1_router.include_router(users_router) +v1_router.include_router(api_clients_router) +v1_router.include_router(audit_router) +v1_router.include_router(system_router) +v1_router.include_router(connectors_router) +v1_router.include_router(erp_systems_router) +v1_router.include_router(medications_router) +v1_router.include_router(catalog_router) +v1_router.include_router(inventory_router) +v1_router.include_router(reservations_router) +v1_router.include_router(sync_jobs_router) diff --git a/pip-platform/src/cli.py b/pip-platform/src/cli.py new file mode 100644 index 0000000..d928acf --- /dev/null +++ b/pip-platform/src/cli.py @@ -0,0 +1,117 @@ +"""CLI entrypoints for the PIP platform.""" +import asyncio +import sys + +import click + +from src.core.logging import configure_logging, get_logger +from src.infrastructure.cache.redis_cache import redis_cache +from src.infrastructure.config.settings import settings +from src.infrastructure.database.session import async_session_factory, engine +from src.infrastructure.messaging.rabbitmq import rabbitmq_client +from src.services.auth.auth_service import AuthService +from src.domain.entities import UserRole + + +@click.group() +def cli(): + """PIP platform CLI.""" + configure_logging() + + +@cli.command() +def run(): + """Start the API server.""" + import uvicorn + uvicorn.run( + "src.main:app", + host="0.0.0.0", + port=8000, + reload=True if settings.DEBUG else False, + log_level=settings.LOG_LEVEL.lower(), + ) + + +@cli.command() +def migrate_upgrade(): + """Apply database migrations.""" + from alembic.config import Config + from alembic import command + + cfg = Config("alembic.ini") + command.upgrade(cfg, "head") + click.echo("Migrations applied.") + + +@cli.command() +def migrate_downgrade(): + """Roll back the last database migration.""" + from alembic.config import Config + from alembic import command + + cfg = Config("alembic.ini") + command.downgrade(cfg, "-1") + click.echo("Rollback complete.") + + +@cli.command() +def migrate_revision(): + """Create a new database revision.""" + from alembic.config import Config + from alembic import command + + message = click.prompt("Revision message") + cfg = Config("alembic.ini") + command.revision(cfg, autogenerate=True, message=message) + click.echo("Revision created.") + + +@cli.command() +@click.option("--username", required=True) +@click.option("--password", required=True) +@click.option("--email", default=None) +@click.option("--role", default="admin", type=click.Choice([r.value for r in UserRole])) +def create_user(username: str, password: str, email: str | None, role: str): + """Create a technical user.""" + asyncio.run(_create_user(username, password, email, role)) + + +async def _create_user(username: str, password: str, email: str | None, role: str): + async with async_session_factory() as session: + service = AuthService(session) + user = await service.register( + username=username, + password=password, + email=email, + role=UserRole(role), + ) + click.echo(f"Created user '{user.username}' with role '{user.role.value}'.") + + +@cli.command() +def health(): + """Check platform health across services.""" + asyncio.run(_health()) + + +async def _health(): + logger = get_logger("cli.health") + async with engine.begin() as conn: + await conn.run_sync(lambda s: None) + click.echo("Database: OK") + + await redis_cache.connect() + ok = await redis_cache.health_check() + click.echo(f"Redis: {'OK' if ok else 'FAIL'}") + await redis_cache.disconnect() + + await rabbitmq_client.connect() + ok = await rabbitmq_client.health_check() + click.echo(f"RabbitMQ: {'OK' if ok else 'FAIL'}") + await rabbitmq_client.disconnect() + + await engine.dispose() + + +if __name__ == "__main__": + cli() diff --git a/pip-platform/src/connectors/__init__.py b/pip-platform/src/connectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/connectors/base/__init__.py b/pip-platform/src/connectors/base/__init__.py new file mode 100644 index 0000000..5791c0f --- /dev/null +++ b/pip-platform/src/connectors/base/__init__.py @@ -0,0 +1,17 @@ +from .base_connector import ( + BaseInventoryConnector, + ConnectorResult, + StockInfo, + PharmacyInfo, + SyncResult, + ReservationResult, +) + +__all__ = [ + "BaseInventoryConnector", + "ConnectorResult", + "StockInfo", + "PharmacyInfo", + "SyncResult", + "ReservationResult", +] diff --git a/pip-platform/src/connectors/base/base_connector.py b/pip-platform/src/connectors/base/base_connector.py new file mode 100644 index 0000000..54c9d1c --- /dev/null +++ b/pip-platform/src/connectors/base/base_connector.py @@ -0,0 +1,78 @@ +from abc import ABC, abstractmethod +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + + +class ConnectorResult(BaseModel): + success: bool + data: Any | None = None + error: str | None = None + metadata: dict | None = None + + +class StockInfo(BaseModel): + medication_id: UUID + nregistro: str + name: str + stock: int + reserved_stock: int = 0 + available_stock: int = 0 + price: float | None = None + last_updated: str | None = None + + +class PharmacyInfo(BaseModel): + pharmacy_id: UUID + code: str + name: str + address: str | None = None + phone: str | None = None + email: str | None = None + opening_hours: dict | None = None + latitude: float | None = None + longitude: float | None = None + + +class SyncResult(BaseModel): + success: bool + items_total: int = 0 + items_processed: int = 0 + items_failed: int = 0 + error_message: str | None = None + metadata: dict | None = None + + +class ReservationResult(BaseModel): + success: bool + reservation_id: str | None = None + error: str | None = None + + +class BaseInventoryConnector(ABC): + connector_type: str = "base" + + @abstractmethod + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + ... + + @abstractmethod + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + ... + + @abstractmethod + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + ... diff --git a/pip-platform/src/connectors/farmatic/__init__.py b/pip-platform/src/connectors/farmatic/__init__.py new file mode 100644 index 0000000..1ffc44b --- /dev/null +++ b/pip-platform/src/connectors/farmatic/__init__.py @@ -0,0 +1,3 @@ +from src.connectors.farmatic.farmatic_connector import FarmaticConnector + +__all__ = ["FarmaticConnector"] diff --git a/pip-platform/src/connectors/farmatic/farmatic_connector.py b/pip-platform/src/connectors/farmatic/farmatic_connector.py new file mode 100644 index 0000000..480ec61 --- /dev/null +++ b/pip-platform/src/connectors/farmatic/farmatic_connector.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.connectors.sdk.base_sdk import SDKConnector +from src.connectors.sdk.context import ConnectorContext +from src.connectors.base import ConnectorResult +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger +from src.core.metrics import CONNECTOR_OPERATIONS + +logger = get_logger("farmatic_connector") + + +class FarmaticConnector(SDKConnector): + connector_type: str = "farmatic" + + def configure(self, config: dict[str, Any]) -> None: + self._base_url = config.get("base_url", "https://erp.farmatic.local/api/v1") + self._timeout = config.get("timeout", 45.0) + super().configure(config) + + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + try: + params: dict[str, Any] = {"pharmacy": str(pharmacy_id)} + if nregistro: + params["nregistro"] = nregistro + if medication_id: + params["medication_id"] = str(medication_id) + + response = await self._request("GET", "/stock", params=params) + data = response.json() + + self._record_metric("get_stock", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("get_stock", "error") + logger.error("farmatic_get_stock_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + try: + payload: dict[str, Any] = { + "pharmacy_id": str(pharmacy_id), + "medication_id": str(medication_id), + "quantity": quantity, + } + if idempotency_key: + payload["idempotency_key"] = idempotency_key + + response = await self._request("POST", "/reservations", json=payload) + data = response.json() + + self._record_metric("reserve", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("reserve", "error") + logger.error("farmatic_reserve_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + try: + response = await self._request("DELETE", f"/reservations/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)}) + data = response.json() + + self._record_metric("cancel_reservation", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("cancel_reservation", "error") + logger.error("farmatic_cancel_error", extra={"reservation_id": reservation_id, "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + try: + response = await self._request("GET", f"/pharmacies/{pharmacy_id}") + data = response.json() + + self._record_metric("pharmacy_information", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("pharmacy_information", "error") + logger.error("farmatic_pharmacy_info_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + try: + payload: dict[str, Any] = { + "pharmacy_id": str(pharmacy_id), + "strategy": strategy, + } + if since: + payload["since"] = since + + response = await self._request("POST", "/sync", json=payload) + data = response.json() + + self._record_metric("synchronize", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("synchronize", "error") + logger.error("farmatic_sync_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)}) + return ConnectorResult(success=False, error=str(exc)) + + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + try: + response = await self._request("GET", "/health") + data = response.json() + data["connector_type"] = self.connector_type + data["pharmacy_id"] = str(pharmacy_id) + data["circuit_breaker"] = self._circuit_breaker.state.value + + self._record_metric("heartbeat", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("heartbeat", "error") + return ConnectorResult( + success=False, + error=str(exc), + metadata={"circuit_breaker": self._circuit_breaker.state.value}, + ) diff --git a/pip-platform/src/connectors/mock/__init__.py b/pip-platform/src/connectors/mock/__init__.py new file mode 100644 index 0000000..b05c2aa --- /dev/null +++ b/pip-platform/src/connectors/mock/__init__.py @@ -0,0 +1,3 @@ +from .mock_connector import MockConnector + +__all__ = ["MockConnector"] diff --git a/pip-platform/src/connectors/mock/mock_connector.py b/pip-platform/src/connectors/mock/mock_connector.py new file mode 100644 index 0000000..ec30abc --- /dev/null +++ b/pip-platform/src/connectors/mock/mock_connector.py @@ -0,0 +1,215 @@ +import asyncio +import random +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.connectors.base import BaseInventoryConnector, ConnectorResult +from src.core.logging import get_logger +from src.core.metrics import CONNECTOR_OPERATIONS + +logger = get_logger("mock_connector") + + +class MockConnector(BaseInventoryConnector): + connector_type: str = "mock" + + def __init__(self) -> None: + self._simulate_latency: bool = True + self._latency_range_ms: tuple[int, int] = (20, 150) + self._error_rate: float = 0.0 + self._stock_data: dict[str, dict[str, Any]] = {} + self._reservations: dict[str, dict[str, Any]] = {} + self._pharmacies: dict[str, dict[str, Any]] = {} + self._init_mock_data() + + def configure(self, config: dict[str, Any]) -> None: + self._simulate_latency = config.get("simulate_latency", True) + self._latency_range_ms = tuple(config.get("latency_range_ms", [20, 150])) + self._error_rate = config.get("error_rate", 0.0) + + def _init_mock_data(self) -> None: + medicamentos = [ + ("00001", "Ibuprofeno 600mg", 100), + ("00002", "Paracetamol 1g", 50), + ("00003", "Amoxicilina 500mg", 200), + ("00004", "Omeprazol 20mg", 75), + ("00005", "Loratadina 10mg", 120), + ("00006", "Diazepam 5mg", 30), + ("00007", "Metformina 850mg", 250), + ("00008", "Atorvastatina 20mg", 80), + ("00009", "Salbutamol Inhalador", 15), + ("00010", "Insulina Glargina", 40), + ] + for nregistro, name, stock in medicamentos: + reserved = random.randint(0, max(stock // 10, 1)) + self._stock_data[nregistro] = { + "nregistro": nregistro, + "name": name, + "stock": stock, + "reserved_stock": reserved, + "available_stock": stock - reserved, + "price": round(random.uniform(2.5, 45.0), 2), + "last_updated": datetime.now(timezone.utc).isoformat(), + } + + self._pharmacies["default"] = { + "code": "FARM-001", + "name": "Farmacia Mock Central", + "address": "Calle Mayor 15, Madrid", + "phone": "+34912345678", + "email": "central@mock-farmacia.es", + "opening_hours": { + "mon_fri": ["09:00", "21:30"], + "sat": ["10:00", "14:00"], + "sun": "closed", + }, + "latitude": 40.4168, + "longitude": -3.7038, + } + self._pharmacies["second"] = { + "code": "FARM-002", + "name": "Farmacia Mock Norte", + "address": "Av. de la Ilustración 1, Madrid", + "phone": "+34915432100", + "email": "norte@mock-farmacia.es", + "opening_hours": { + "mon_fri": ["08:30", "22:00"], + "sat": ["09:00", "22:00"], + "sun": ["10:00", "14:00"], + }, + "latitude": 40.4722, + "longitude": -3.6934, + } + + async def _maybe_delay(self) -> None: + if self._simulate_latency: + low, high = self._latency_range_ms + delay = random.randint(low, high) / 1000.0 + await asyncio.sleep(delay) + + def _maybe_error(self) -> None: + if self._error_rate > 0 and random.random() < self._error_rate: + error_types = [ + ("TIMEOUT", "Connection timed out"), + ("CONNECTION_REFUSED", "ERP server refused connection"), + ("AUTH_FAILED", "Authentication failed"), + ("RATE_LIMITED", "ERP rate limit exceeded"), + ("INTERNAL_ERROR", "ERP internal server error"), + ] + code, msg = random.choice(error_types) + from src.core.exceptions import ConnectorException + raise ConnectorException(self.connector_type, msg, details={"error_code": code}) + + def _metric(self, operation: str, status: str) -> None: + CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc() + + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_get_stock", extra={"pharmacy_id": str(pharmacy_id), "nregistro": nregistro}) + + if nregistro and nregistro in self._stock_data: + data = dict(self._stock_data[nregistro]) + data["pharmacy_id"] = str(pharmacy_id) + self._metric("get_stock", "success") + return ConnectorResult(success=True, data=data) + + if nregistro is None and medication_id is None: + results = [dict(v) for v in self._stock_data.values()] + for r in results: + r["pharmacy_id"] = str(pharmacy_id) + self._metric("get_stock", "success") + return ConnectorResult(success=True, data={"items": results, "total": len(results)}) + + self._metric("get_stock", "not_found") + return ConnectorResult(success=False, error=f"Medication '{nregistro}' not found in mock data") + + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_reserve", extra={"pharmacy_id": str(pharmacy_id), "quantity": quantity}) + + if idempotency_key and idempotency_key in self._reservations: + self._metric("reserve", "idempotent") + return ConnectorResult(success=True, data=self._reservations[idempotency_key]) + + reservation_id = f"mock-res-{idempotency_key or random.randint(100000, 999999)}" + reservation_data = { + "reservation_id": reservation_id, + "status": "confirmed", + "quantity": quantity, + "pharmacy_id": str(pharmacy_id), + "medication_id": str(medication_id), + "confirmed_at": datetime.now(timezone.utc).isoformat(), + "expires_at": datetime.now(timezone.utc).isoformat(), + } + self._reservations[reservation_id] = reservation_data + if idempotency_key: + self._reservations[idempotency_key] = reservation_data + + self._metric("reserve", "success") + return ConnectorResult(success=True, data=reservation_data) + + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_cancel_reservation", extra={"reservation_id": reservation_id}) + + if reservation_id in self._reservations: + self._reservations[reservation_id]["status"] = "cancelled" + self._metric("cancel_reservation", "success") + return ConnectorResult(success=True, data={"reservation_id": reservation_id, "status": "cancelled"}) + + self._metric("cancel_reservation", "not_found") + return ConnectorResult(success=False, error=f"Reservation '{reservation_id}' not found") + + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_pharmacy_information", extra={"pharmacy_id": str(pharmacy_id)}) + + pharmacy = list(self._pharmacies.values())[0] + result = dict(pharmacy) + result["pharmacy_id"] = str(pharmacy_id) + self._metric("pharmacy_information", "success") + return ConnectorResult(success=True, data=result) + + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + await self._maybe_delay() + self._maybe_error() + logger.info("mock_synchronize", extra={"pharmacy_id": str(pharmacy_id), "strategy": strategy, "since": since}) + + items_total = len(self._stock_data) + items_processed = items_total + items_failed = random.randint(0, min(2, items_total)) if self._error_rate > 0 else 0 + if items_failed > 0: + items_processed = items_total - items_failed + + self._metric("synchronize", "success") + return ConnectorResult( + success=True, + data={ + "items_total": items_total, + "items_processed": items_processed, + "items_failed": items_failed, + "strategy": strategy, + "synced_at": datetime.now(timezone.utc).isoformat(), + "pharmacy_id": str(pharmacy_id), + }, + ) + + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + self._metric("heartbeat", "success") + return ConnectorResult( + success=True, + data={ + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "connector_type": self.connector_type, + "pharmacy_id": str(pharmacy_id), + "circuit_breaker": "closed", + "stock_items": len(self._stock_data), + "active_reservations": len([r for r in self._reservations.values() if r.get("status") == "confirmed"]), + }, + ) diff --git a/pip-platform/src/connectors/registry.py b/pip-platform/src/connectors/registry.py new file mode 100644 index 0000000..de251a4 --- /dev/null +++ b/pip-platform/src/connectors/registry.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import importlib +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.connectors.base import BaseInventoryConnector, ConnectorResult +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger +from src.core.metrics import CONNECTOR_OPERATIONS +from src.domain.entities import ConnectorStatus, ERPType +from src.infrastructure.cache.redis_cache import redis_cache +from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("connector_registry") + +_BUILTIN_CONNECTORS: dict[str, str] = { + ERPType.MOCK.value: "src.connectors.mock.mock_connector.MockConnector", + ERPType.FARMATIC.value: "src.connectors.farmatic.farmatic_connector.FarmaticConnector", + ERPType.CUSTOM_REST.value: "src.connectors.rest.rest_connector.RESTConnector", +} + + +class ConnectorRegistry: + _instance: ConnectorRegistry | None = None + _connectors: dict[str, BaseInventoryConnector] + _instances: dict[UUID, BaseInventoryConnector] + + def __init__(self) -> None: + self._connectors = {} + self._instances = {} + + @classmethod + def get_registry(cls) -> ConnectorRegistry: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def register(self, connector_type: str, connector: BaseInventoryConnector) -> None: + self._connectors[connector_type] = connector + logger.info("connector_registered", extra={"connector_type": connector_type}) + + def unregister(self, connector_type: str) -> None: + self._connectors.pop(connector_type, None) + logger.info("connector_unregistered", extra={"connector_type": connector_type}) + + def get_connector_class(self, connector_type: str) -> type[BaseInventoryConnector]: + if connector_type in self._connectors: + return type(self._connectors[connector_type]) + + class_path = _BUILTIN_CONNECTORS.get(connector_type) + if not class_path: + raise ConnectorException(connector_type, f"No connector registered for type '{connector_type}'") + + try: + module_path, class_name = class_path.rsplit(".", 1) + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + self._connectors[connector_type] = cls() + return cls + except (ImportError, AttributeError) as e: + raise ConnectorException(connector_type, f"Failed to load connector '{connector_type}': {e}") from e + + def create_instance(self, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector: + cls = self.get_connector_class(connector_type) + instance = cls() + if config and hasattr(instance, "configure"): + instance.configure(config) + return instance + + def get_or_create_instance(self, connector_id: UUID, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector: + if connector_id in self._instances: + return self._instances[connector_id] + instance = self.create_instance(connector_type, config) + self._instances[connector_id] = instance + return instance + + def remove_instance(self, connector_id: UUID) -> None: + self._instances.pop(connector_id, None) + + def list_registered_types(self) -> list[str]: + all_types = set(self._connectors.keys()) | set(_BUILTIN_CONNECTORS.keys()) + return sorted(all_types) + + @staticmethod + async def record_heartbeat(repo: ConnectorRepository, connector_id: UUID) -> None: + entity = await repo.get_by_id(connector_id) + if entity: + entity.last_heartbeat_at = datetime.now(timezone.utc) + entity.status = ConnectorStatus.ACTIVE + entity.last_error = None + await repo.update(entity) + + @staticmethod + async def record_error(repo: ConnectorRepository, connector_id: UUID, error: str) -> None: + entity = await repo.get_by_id(connector_id) + if entity: + entity.last_error = error + entity.status = ConnectorStatus.ERROR + await repo.update(entity) + + +connector_registry = ConnectorRegistry.get_registry() diff --git a/pip-platform/src/connectors/rest/__init__.py b/pip-platform/src/connectors/rest/__init__.py new file mode 100644 index 0000000..53a0439 --- /dev/null +++ b/pip-platform/src/connectors/rest/__init__.py @@ -0,0 +1,3 @@ +from src.connectors.rest.rest_connector import RESTConnector + +__all__ = ["RESTConnector"] diff --git a/pip-platform/src/connectors/rest/rest_connector.py b/pip-platform/src/connectors/rest/rest_connector.py new file mode 100644 index 0000000..3937818 --- /dev/null +++ b/pip-platform/src/connectors/rest/rest_connector.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from src.connectors.sdk.base_sdk import SDKConnector +from src.connectors.base import ConnectorResult +from src.core.logging import get_logger + +logger = get_logger("rest_connector") + + +class RESTConnector(SDKConnector): + connector_type: str = "custom_rest" + + _STOCK_PATH: str = "/stock" + _RESERVE_PATH: str = "/reservations" + _PHARMACY_PATH: str = "/pharmacy" + _SYNC_PATH: str = "/sync" + _HEALTH_PATH: str = "/health" + + def configure(self, config: dict[str, Any]) -> None: + self._base_url = config.get("base_url", "") + self._timeout = config.get("timeout", 30.0) + self._STOCK_PATH = config.get("stock_path", self._STOCK_PATH) + self._RESERVE_PATH = config.get("reserve_path", self._RESERVE_PATH) + self._PHARMACY_PATH = config.get("pharmacy_path", self._PHARMACY_PATH) + self._SYNC_PATH = config.get("sync_path", self._SYNC_PATH) + self._HEALTH_PATH = config.get("health_path", self._HEALTH_PATH) + super().configure(config) + + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + try: + params: dict[str, Any] = {"pharmacy_id": str(pharmacy_id)} + if nregistro: + params["nregistro"] = nregistro + if medication_id: + params["medication_id"] = str(medication_id) + response = await self._request("GET", self._STOCK_PATH, params=params) + self._record_metric("get_stock", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("get_stock", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + try: + payload: dict[str, Any] = { + "pharmacy_id": str(pharmacy_id), + "medication_id": str(medication_id), + "quantity": quantity, + } + if idempotency_key: + payload["idempotency_key"] = idempotency_key + response = await self._request("POST", self._RESERVE_PATH, json=payload) + self._record_metric("reserve", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("reserve", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + try: + response = await self._request("DELETE", f"{self._RESERVE_PATH}/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)}) + self._record_metric("cancel_reservation", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("cancel_reservation", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + try: + response = await self._request("GET", f"{self._PHARMACY_PATH}/{pharmacy_id}") + self._record_metric("pharmacy_information", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("pharmacy_information", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + try: + payload: dict[str, Any] = {"pharmacy_id": str(pharmacy_id), "strategy": strategy} + if since: + payload["since"] = since + response = await self._request("POST", self._SYNC_PATH, json=payload) + self._record_metric("synchronize", "success") + return ConnectorResult(success=True, data=response.json()) + except Exception as exc: + self._record_metric("synchronize", "error") + return ConnectorResult(success=False, error=str(exc)) + + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + try: + response = await self._request("GET", self._HEALTH_PATH) + data = response.json() + data["connector_type"] = self.connector_type + data["pharmacy_id"] = str(pharmacy_id) + data["circuit_breaker"] = self._circuit_breaker.state.value + self._record_metric("heartbeat", "success") + return ConnectorResult(success=True, data=data) + except Exception as exc: + self._record_metric("heartbeat", "error") + return ConnectorResult(success=False, error=str(exc)) diff --git a/pip-platform/src/connectors/sdk/__init__.py b/pip-platform/src/connectors/sdk/__init__.py new file mode 100644 index 0000000..e96b3df --- /dev/null +++ b/pip-platform/src/connectors/sdk/__init__.py @@ -0,0 +1,19 @@ +from .context import ConnectorContext +from .auth import AuthHandler, BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler, OAuth2Handler +from .retry import RetryPolicy, with_retry +from .circuit_breaker import CircuitBreaker, CircuitState +from .base_sdk import SDKConnector + +__all__ = [ + "APIKeyAuthHandler", + "AuthHandler", + "BasicAuthHandler", + "BearerAuthHandler", + "CircuitBreaker", + "CircuitState", + "ConnectorContext", + "OAuth2Handler", + "RetryPolicy", + "SDKConnector", + "with_retry", +] diff --git a/pip-platform/src/connectors/sdk/auth.py b/pip-platform/src/connectors/sdk/auth.py new file mode 100644 index 0000000..908fcf0 --- /dev/null +++ b/pip-platform/src/connectors/sdk/auth.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +import httpx + +from src.connectors.sdk.context import ConnectorContext +from src.core.logging import get_logger + +logger = get_logger("connector_auth") + + +class AuthHandler(ABC): + @abstractmethod + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + ... + + @abstractmethod + async def refresh(self, context: ConnectorContext) -> bool: + return True + + +class BasicAuthHandler(AuthHandler): + def __init__(self, username_key: str = "username", password_key: str = "password") -> None: + self._username_key = username_key + self._password_key = password_key + + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + import base64 + + username = context.get_secret(self._username_key) or context.get_config(self._username_key, "") + password = context.get_secret(self._password_key) or context.get_config(self._password_key, "") + credentials = base64.b64encode(f"{username}:{password}".encode()).decode() + request.headers["Authorization"] = f"Basic {credentials}" + return request + + async def refresh(self, context: ConnectorContext) -> bool: + return True + + +class BearerAuthHandler(AuthHandler): + def __init__(self, token_key: str = "access_token") -> None: + self._token_key = token_key + self._token: str | None = None + + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + token = self._token or context.get_secret(self._token_key) or context.get_config(self._token_key, "") + if token: + request.headers["Authorization"] = f"Bearer {token}" + return request + + async def refresh(self, context: ConnectorContext) -> bool: + self._token = None + return True + + +class APIKeyAuthHandler(AuthHandler): + def __init__(self, header_name: str = "X-API-Key", key_secret: str = "api_key") -> None: + self._header_name = header_name + self._key_secret = key_secret + + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + api_key = context.get_secret(self._key_secret) or context.get_config(self._key_secret, "") + if api_key: + request.headers[self._header_name] = api_key + return request + + async def refresh(self, context: ConnectorContext) -> bool: + return True + + +class OAuth2Handler(AuthHandler): + def __init__( + self, + token_url_key: str = "token_url", + client_id_key: str = "client_id", + client_secret_key: str = "client_secret", + ) -> None: + self._token_url_key = token_url_key + self._client_id_key = client_id_key + self._client_secret_key = client_secret_key + self._access_token: str | None = None + self._token_expires: float = 0 + + async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request: + import time + + if not self._access_token or time.time() >= self._token_expires: + await self._fetch_token(context) + + if self._access_token: + request.headers["Authorization"] = f"Bearer {self._access_token}" + return request + + async def _fetch_token(self, context: ConnectorContext) -> None: + import time + + token_url = context.require_config(self._token_url_key) + client_id = context.require_secret(self._client_id_key) + client_secret = context.require_secret(self._client_secret_key) + + async with httpx.AsyncClient() as client: + resp = await client.post( + token_url, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + resp.raise_for_status() + data = resp.json() + self._access_token = data["access_token"] + self._token_expires = time.time() + data.get("expires_in", 3600) - 30 + + logger.info("oauth2_token_acquired", extra={"token_url": token_url}) + + async def refresh(self, context: ConnectorContext) -> bool: + self._access_token = None + self._token_expires = 0 + return True diff --git a/pip-platform/src/connectors/sdk/base_sdk.py b/pip-platform/src/connectors/sdk/base_sdk.py new file mode 100644 index 0000000..b270053 --- /dev/null +++ b/pip-platform/src/connectors/sdk/base_sdk.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import Any +from uuid import UUID + +import httpx + +from src.connectors.base import BaseInventoryConnector, ConnectorResult +from src.connectors.sdk.auth import AuthHandler, BearerAuthHandler +from src.connectors.sdk.circuit_breaker import CircuitBreaker +from src.connectors.sdk.context import ConnectorContext +from src.connectors.sdk.retry import RetryPolicy, with_retry +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger +from src.core.metrics import CONNECTOR_OPERATIONS + +logger = get_logger("sdk_connector") + + +class SDKConnector(BaseInventoryConnector): + connector_type: str = "sdk" + + def __init__(self) -> None: + self._context: ConnectorContext | None = None + self._auth_handler: AuthHandler = BearerAuthHandler() + self._retry_policy: RetryPolicy = RetryPolicy() + self._circuit_breaker: CircuitBreaker = CircuitBreaker(name=self.connector_type) + self._http_client: httpx.AsyncClient | None = None + self._base_url: str = "" + self._timeout: float = 30.0 + + def configure(self, config: dict[str, Any]) -> None: + self._base_url = config.get("base_url", "") + self._timeout = config.get("timeout", 30.0) + auth_type = config.get("auth_type", "bearer") + if auth_type == "basic": + from src.connectors.sdk.auth import BasicAuthHandler + self._auth_handler = BasicAuthHandler() + elif auth_type == "api_key": + from src.connectors.sdk.auth import APIKeyAuthHandler + self._auth_handler = APIKeyAuthHandler( + header_name=config.get("api_key_header", "X-API-Key"), + key_secret=config.get("api_key_secret_key", "api_key"), + ) + elif auth_type == "oauth2": + from src.connectors.sdk.auth import OAuth2Handler + self._auth_handler = OAuth2Handler() + + cb_config = config.get("circuit_breaker", {}) + if cb_config: + self._circuit_breaker = CircuitBreaker( + name=self.connector_type, + failure_threshold=cb_config.get("failure_threshold", 5), + recovery_timeout=cb_config.get("recovery_timeout", 30.0), + ) + + retry_config = config.get("retry", {}) + if retry_config: + self._retry_policy = RetryPolicy( + max_attempts=retry_config.get("max_attempts", 3), + base_delay=retry_config.get("base_delay", 1.0), + max_delay=retry_config.get("max_delay", 30.0), + ) + + def set_context(self, context: ConnectorContext) -> None: + self._context = context + if context.config: + self.configure(context.config) + + @property + def http_client(self) -> httpx.AsyncClient: + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient( + base_url=self._base_url, + timeout=self._timeout, + ) + return self._http_client + + async def _request( + self, + method: str, + path: str, + *, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + if not self._context: + raise ConnectorException(self.connector_type, "Connector context not set. Call set_context() first.") + + async def _do_request() -> httpx.Response: + request = self.http_client.build_request(method, path, json=json, params=params, headers=headers) + request = await self._auth_handler.apply(request, self._context) + response = await self.http_client.send(request) + response.raise_for_status() + return response + + return await self._circuit_breaker.call( + with_retry, + _do_request, + policy=self._retry_policy, + connector_type=self.connector_type, + operation=f"{method}:{path}", + ) + + def _record_metric(self, operation: str, status: str) -> None: + CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc() + + async def close(self) -> None: + if self._http_client and not self._http_client.is_closed: + await self._http_client.aclose() + self._http_client = None + + @abstractmethod + async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult: + ... + + @abstractmethod + async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult: + ... + + @abstractmethod + async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult: + ... + + @abstractmethod + async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult: + ... diff --git a/pip-platform/src/connectors/sdk/circuit_breaker.py b/pip-platform/src/connectors/sdk/circuit_breaker.py new file mode 100644 index 0000000..deb56f1 --- /dev/null +++ b/pip-platform/src/connectors/sdk/circuit_breaker.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import time +from enum import Enum +from typing import Any, Callable + +from src.core.exceptions import ConnectorException, ServiceUnavailableException +from src.core.logging import get_logger + +logger = get_logger("circuit_breaker") + + +class CircuitState(str, Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +class CircuitBreaker: + def __init__( + self, + name: str, + failure_threshold: int = 5, + recovery_timeout: float = 30.0, + half_open_max_calls: int = 1, + ) -> None: + self._name = name + self._failure_threshold = failure_threshold + self._recovery_timeout = recovery_timeout + self._half_open_max_calls = half_open_max_calls + + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._success_count = 0 + self._last_failure_time: float = 0 + self._half_open_calls: int = 0 + + @property + def state(self) -> CircuitState: + if self._state == CircuitState.OPEN: + if time.monotonic() - self._last_failure_time >= self._recovery_timeout: + self._state = CircuitState.HALF_OPEN + self._half_open_calls = 0 + logger.info("circuit_half_open", extra={"name": self._name}) + return self._state + + async def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: + current_state = self.state + + if current_state == CircuitState.OPEN: + raise ServiceUnavailableException( + f"circuit_breaker:{self._name}", + f"Circuit breaker '{self._name}' is open. Retry after {self._recovery_timeout}s.", + ) + + if current_state == CircuitState.HALF_OPEN: + if self._half_open_calls >= self._half_open_max_calls: + raise ServiceUnavailableException( + f"circuit_breaker:{self._name}", + f"Circuit breaker '{self._name}' is half-open with max concurrent calls reached.", + ) + self._half_open_calls += 1 + + try: + result = await func(*args, **kwargs) + self._on_success() + return result + except Exception as exc: + self._on_failure(exc) + raise + + def _on_success(self) -> None: + if self._state == CircuitState.HALF_OPEN: + self._state = CircuitState.CLOSED + logger.info("circuit_closed", extra={"name": self._name}) + self._failure_count = 0 + self._success_count += 1 + + def _on_failure(self, exc: Exception) -> None: + self._failure_count += 1 + self._last_failure_time = time.monotonic() + self._success_count = 0 + + if self._state == CircuitState.HALF_OPEN: + self._state = CircuitState.OPEN + logger.warning("circuit_reopened", extra={"name": self._name, "error": str(exc)}) + elif self._failure_count >= self._failure_threshold: + self._state = CircuitState.OPEN + logger.warning("circuit_opened", extra={"name": self._name, "failure_count": self._failure_count}) + + def reset(self) -> None: + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._success_count = 0 + self._last_failure_time = 0 + + def get_status(self) -> dict[str, Any]: + return { + "name": self._name, + "state": self.state.value, + "failure_count": self._failure_count, + "success_count": self._success_count, + "failure_threshold": self._failure_threshold, + "recovery_timeout": self._recovery_timeout, + "last_failure_time": self._last_failure_time, + } diff --git a/pip-platform/src/connectors/sdk/context.py b/pip-platform/src/connectors/sdk/context.py new file mode 100644 index 0000000..1d95bfd --- /dev/null +++ b/pip-platform/src/connectors/sdk/context.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from uuid import UUID + + +@dataclass +class ConnectorContext: + connector_id: UUID + pharmacy_id: UUID + erp_system_id: UUID + connector_type: str + config: dict[str, Any] = field(default_factory=dict) + secrets: dict[str, str] = field(default_factory=dict) + + _cache_prefix: str = field(default="connector", init=False, repr=False) + + @property + def cache_key_prefix(self) -> str: + return f"{self._cache_prefix}:{self.connector_type}:{self.pharmacy_id}" + + def cache_key(self, suffix: str) -> str: + return f"{self.cache_key_prefix}:{suffix}" + + def get_config(self, key: str, default: Any = None) -> Any: + return self.config.get(key, default) + + def get_secret(self, key: str) -> str | None: + return self.secrets.get(key) + + def require_secret(self, key: str) -> str: + value = self.secrets.get(key) + if value is None: + raise ValueError(f"Required secret '{key}' not found in connector context") + return value + + def require_config(self, key: str) -> Any: + value = self.config.get(key) + if value is None: + raise ValueError(f"Required config '{key}' not found in connector context") + return value diff --git a/pip-platform/src/connectors/sdk/retry.py b/pip-platform/src/connectors/sdk/retry.py new file mode 100644 index 0000000..a21b7d5 --- /dev/null +++ b/pip-platform/src/connectors/sdk/retry.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from functools import wraps +from typing import Any, Callable, TypeVar + +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger + +logger = get_logger("connector_retry") + +T = TypeVar("T") + +DEFAULT_RETRYABLE_EXCEPTIONS = (ConnectionError, TimeoutError, OSError) + + +@dataclass +class RetryPolicy: + max_attempts: int = 3 + base_delay: float = 1.0 + max_delay: float = 30.0 + exponential_base: float = 2.0 + jitter: bool = True + retryable_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: DEFAULT_RETRYABLE_EXCEPTIONS) + + def delay_for_attempt(self, attempt: int) -> float: + import random + + delay = min(self.base_delay * (self.exponential_base ** (attempt - 1)), self.max_delay) + if self.jitter: + delay = delay * (0.5 + random.random() * 0.5) + return delay + + def should_retry(self, exception: Exception) -> bool: + if isinstance(exception, ConnectorException): + return False + return isinstance(exception, self.retryable_exceptions) + + +async def with_retry( + func: Callable[..., Any], + *args: Any, + policy: RetryPolicy | None = None, + connector_type: str = "unknown", + operation: str = "unknown", + **kwargs: Any, +) -> Any: + p = policy or RetryPolicy() + last_exception: Exception | None = None + + for attempt in range(1, p.max_attempts + 1): + try: + result = await func(*args, **kwargs) + if attempt > 1: + logger.info("retry_succeeded", extra={"connector_type": connector_type, "operation": operation, "attempt": attempt}) + return result + except Exception as exc: + last_exception = exc + if not p.should_retry(exc) or attempt >= p.max_attempts: + break + delay = p.delay_for_attempt(attempt) + logger.warning( + "retry_attempt", + extra={ + "connector_type": connector_type, + "operation": operation, + "attempt": attempt, + "delay_s": round(delay, 2), + "error": str(exc), + }, + ) + await asyncio.sleep(delay) + + raise last_exception # type: ignore[misc] + + +def retry(policy: RetryPolicy | None = None): + p = policy or RetryPolicy() + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + connector_type = getattr(args[0], "connector_type", "unknown") if args else "unknown" + return await with_retry(func, *args, policy=p, connector_type=connector_type, operation=func.__name__, **kwargs) + return wrapper + return decorator diff --git a/pip-platform/src/core/__init__.py b/pip-platform/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/core/exceptions.py b/pip-platform/src/core/exceptions.py new file mode 100644 index 0000000..14418a7 --- /dev/null +++ b/pip-platform/src/core/exceptions.py @@ -0,0 +1,77 @@ +from datetime import datetime, timezone + + +class PIPException(Exception): + def __init__(self, message: str, code: str = "PIP_ERROR", status_code: int = 500, details: dict | None = None): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.details = details or {} + self.timestamp = datetime.now(timezone.utc).isoformat() + + def to_dict(self) -> dict: + return { + "error": { + "code": self.code, + "message": self.message, + "details": self.details, + "timestamp": self.timestamp, + } + } + + +class NotFoundException(PIPException): + def __init__(self, resource: str, resource_id: str | None = None): + msg = f"{resource} not found" + if resource_id: + msg = f"{resource} '{resource_id}' not found" + super().__init__(message=msg, code="NOT_FOUND", status_code=404, details={"resource": resource, "resource_id": resource_id}) + + +class ConflictException(PIPException): + def __init__(self, message: str, details: dict | None = None): + super().__init__(message=message, code="CONFLICT", status_code=409, details=details) + + +class ValidationException(PIPException): + def __init__(self, message: str, details: dict | None = None): + super().__init__(message=message, code="VALIDATION_ERROR", status_code=422, details=details) + + +class UnauthorizedException(PIPException): + def __init__(self, message: str = "Authentication required"): + super().__init__(message=message, code="UNAUTHORIZED", status_code=401) + + +class ForbiddenException(PIPException): + def __init__(self, message: str = "Insufficient permissions", required_role: str | None = None): + details = {} + if required_role: + details["required_role"] = required_role + super().__init__(message=message, code="FORBIDDEN", status_code=403, details=details) + + +class RateLimitException(PIPException): + def __init__(self, message: str = "Rate limit exceeded"): + super().__init__(message=message, code="RATE_LIMIT_EXCEEDED", status_code=429) + + +class ServiceUnavailableException(PIPException): + def __init__(self, service: str, message: str | None = None): + msg = message or f"Service '{service}' is unavailable" + super().__init__(message=msg, code="SERVICE_UNAVAILABLE", status_code=503, details={"service": service}) + + +class ConnectorException(PIPException): + def __init__(self, connector_type: str, message: str, details: dict | None = None): + d = details or {} + d["connector_type"] = connector_type + super().__init__(message=message, code="CONNECTOR_ERROR", status_code=502, details=d) + + +class SyncException(PIPException): + def __init__(self, pharmacy_id: str, message: str, details: dict | None = None): + d = details or {} + d["pharmacy_id"] = pharmacy_id + super().__init__(message=message, code="SYNC_ERROR", status_code=502, details=d) diff --git a/pip-platform/src/core/logging.py b/pip-platform/src/core/logging.py new file mode 100644 index 0000000..6a54cd0 --- /dev/null +++ b/pip-platform/src/core/logging.py @@ -0,0 +1,56 @@ +import logging +import sys +from typing import Any + +import structlog + +from src.infrastructure.config.settings import settings + + +def configure_logging() -> None: + shared_processors: list[Any] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.UnicodeDecoder(), + ] + + if settings.LOG_JSON_FORMAT: + renderer = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer() + + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + foreign_pre_chain=shared_processors, + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(settings.LOG_LEVEL.upper()) + + for noisy in ("uvicorn.access", "sqlalchemy.engine", "httpx", "aioredis"): + logging.getLogger(noisy).setLevel(logging.WARNING) + + +def get_logger(name: str | None = None) -> Any: + return structlog.get_logger(name) diff --git a/pip-platform/src/core/metrics.py b/pip-platform/src/core/metrics.py new file mode 100644 index 0000000..5c3792e --- /dev/null +++ b/pip-platform/src/core/metrics.py @@ -0,0 +1,94 @@ +import time +from collections.abc import Callable +from typing import Any + +from fastapi import Request, Response +from prometheus_client import Counter, Gauge, Histogram + +REQUEST_COUNT = Counter( + "pip_http_requests_total", + "Total HTTP requests", + ["method", "endpoint", "status_code"], +) + +REQUEST_DURATION = Histogram( + "pip_http_request_duration_seconds", + "HTTP request duration in seconds", + ["method", "endpoint"], + buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0], +) + +REQUESTS_IN_PROGRESS = Gauge( + "pip_http_requests_in_progress", + "HTTP requests currently in progress", + ["method", "endpoint"], +) + +DB_QUERY_DURATION = Histogram( + "pip_db_query_duration_seconds", + "Database query duration in seconds", + ["operation"], + buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0], +) + +CONNECTOR_OPERATIONS = Counter( + "pip_connector_operations_total", + "Total connector operations", + ["connector_type", "operation", "status"], +) + +SYNC_JOBS = Counter( + "pip_sync_jobs_total", + "Total sync jobs", + ["strategy", "status"], +) + +SYNC_DURATION = Histogram( + "pip_sync_duration_seconds", + "Sync job duration in seconds", + ["strategy"], + buckets=[1.0, 5.0, 10.0, 30.0, 60.0, 300.0, 600.0, 1800.0], +) + +ACTIVE_CONNECTIONS = Gauge( + "pip_active_connections", + "Active infrastructure connections", + ["type"], +) + +CACHE_OPERATIONS = Counter( + "pip_cache_operations_total", + "Cache operations", + ["operation", "result"], +) + +RESERVATION_OPERATIONS = Counter( + "pip_reservation_operations_total", + "Reservation operations", + ["operation", "status"], +) + + +async def metrics_middleware(request: Request, call_next: Callable) -> Response: + if request.url.path == "/metrics" or request.url.path.startswith("/docs") or request.url.path.startswith("/openapi"): + return await call_next(request) + + method = request.method + endpoint = request.url.path + + REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).inc() + + start_time = time.perf_counter() + try: + response = await call_next(request) + status_code = response.status_code + except Exception: + status_code = 500 + raise + finally: + duration = time.perf_counter() - start_time + REQUEST_COUNT.labels(method=method, endpoint=endpoint, status_code=status_code).inc() + REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration) + REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).dec() + + return response diff --git a/pip-platform/src/core/tracing.py b/pip-platform/src/core/tracing.py new file mode 100644 index 0000000..80842ab --- /dev/null +++ b/pip-platform/src/core/tracing.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from src.infrastructure.config.settings import settings + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncEngine + + +def setup_tracing(app: Any = None, engine: AsyncEngine | None = None) -> None: + if not settings.OTEL_TRACES_ENABLED: + return + + resource = Resource.create({"service.name": settings.OTEL_SERVICE_NAME}) + + provider = TracerProvider(resource=resource) + + otlp_exporter = OTLPSpanExporter(endpoint=settings.OTEL_EXPORTER_OTLP_ENDPOINT) + provider.add_span_processor(BatchSpanProcessor(otlp_exporter)) + + trace.set_tracer_provider(provider) + + if engine is not None: + sync_engine = engine.sync_engine if hasattr(engine, "sync_engine") else engine + SQLAlchemyInstrumentor().instrument(engine=sync_engine) + RedisInstrumentor().instrument() + + if app is not None: + FastAPIInstrumentor.instrument_app(app) + + +def get_tracer(name: str = "pip-platform") -> trace.Tracer: + return trace.get_tracer(name) diff --git a/pip-platform/src/domain/__init__.py b/pip-platform/src/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/domain/entities/__init__.py b/pip-platform/src/domain/entities/__init__.py new file mode 100644 index 0000000..619134d --- /dev/null +++ b/pip-platform/src/domain/entities/__init__.py @@ -0,0 +1,51 @@ +from .entities import ( + APIClientEntity, + APIKeyEntity, + AuditLogEntity, + ConnectorConfigEntity, + ConnectorEntity, + ConnectorStatus, + ConnectorVersionEntity, + ERPType, + ERPSystemEntity, + InventoryHistoryEntity, + InventoryRecordEntity, + MedicationCatalogEntryEntity, + MedicationEntity, + PharmacyEntity, + PharmacyStatus, + ReservationEntity, + ReservationStatus, + SyncJobEntity, + SyncJobStatus, + SyncLogEntity, + SyncStrategy, + TechnicalUserEntity, + UserRole, +) + +__all__ = [ + "APIClientEntity", + "APIKeyEntity", + "AuditLogEntity", + "ConnectorConfigEntity", + "ConnectorEntity", + "ConnectorStatus", + "ConnectorVersionEntity", + "ERPType", + "ERPSystemEntity", + "InventoryHistoryEntity", + "InventoryRecordEntity", + "MedicationCatalogEntryEntity", + "MedicationEntity", + "PharmacyEntity", + "PharmacyStatus", + "ReservationEntity", + "ReservationStatus", + "SyncJobEntity", + "SyncJobStatus", + "SyncLogEntity", + "SyncStrategy", + "TechnicalUserEntity", + "UserRole", +] diff --git a/pip-platform/src/domain/entities/entities.py b/pip-platform/src/domain/entities/entities.py new file mode 100644 index 0000000..4873f7a --- /dev/null +++ b/pip-platform/src/domain/entities/entities.py @@ -0,0 +1,317 @@ +import uuid +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field + + +class PharmacyStatus(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + PENDING = "pending" + + +class ERPType(str, Enum): + FARMATIC = "farmatic" + NIXFARMA = "nixfarma" + UNYCOP = "unycop" + CUSTOM_REST = "custom_rest" + CUSTOM_SOAP = "custom_soap" + CSV = "csv" + FTP = "ftp" + SQL = "sql" + MOCK = "mock" + + +class ConnectorStatus(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + PENDING = "pending" + UPDATING = "updating" + + +class SyncStrategy(str, Enum): + FULL = "full" + INCREMENTAL = "incremental" + EVENT_DRIVEN = "event_driven" + SCHEDULED = "scheduled" + HYBRID = "hybrid" + + +class SyncJobStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class ReservationStatus(str, Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + EXPIRED = "expired" + + +class UserRole(str, Enum): + SUPERADMIN = "superadmin" + ADMIN = "admin" + OPERATOR = "operator" + VIEWER = "viewer" + API_CLIENT = "api_client" + + +class PharmacyEntity(BaseModel): + id: uuid.UUID | None = None + code: str + name: str + address: str | None = None + city: str | None = None + province: str | None = None + postal_code: str | None = None + country: str = "ES" + latitude: float | None = None + longitude: float | None = None + phone: str | None = None + email: str | None = None + opening_hours: dict | None = None + status: PharmacyStatus = PharmacyStatus.PENDING + erp_type: ERPType | None = None + erp_version: str | None = None + connector_id: uuid.UUID | None = None + sync_strategy: SyncStrategy = SyncStrategy.INCREMENTAL + sync_interval_minutes: int = 30 + last_sync_at: datetime | None = None + last_sync_status: SyncJobStatus | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class ERPSystemEntity(BaseModel): + id: uuid.UUID | None = None + name: str + erp_type: ERPType + version: str | None = None + base_url: str | None = None + auth_config: dict | None = None + status: ConnectorStatus = ConnectorStatus.PENDING + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class ConnectorEntity(BaseModel): + id: uuid.UUID | None = None + erp_system_id: uuid.UUID + pharmacy_id: uuid.UUID + name: str + connector_type: ERPType + version: str = "1.0.0" + config: dict | None = None + status: ConnectorStatus = ConnectorStatus.PENDING + last_heartbeat_at: datetime | None = None + last_error: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class ConnectorVersionEntity(BaseModel): + id: uuid.UUID | None = None + connector_id: uuid.UUID | None = None + connector_type: ERPType + version: str + changelog: str | None = None + download_url: str | None = None + checksum: str | None = None + is_latest: bool = False + is_required: bool = False + released_at: datetime | None = None + created_at: datetime | None = None + + +class ConnectorConfigEntity(BaseModel): + id: uuid.UUID | None = None + connector_id: uuid.UUID + key: str + value: str + encrypted: bool = False + description: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class MedicationEntity(BaseModel): + id: uuid.UUID | None = None + nregistro: str + name: str + active_ingredient: str | None = None + dosage: str | None = None + form: str | None = None + atc_code: str | None = None + prescription_required: bool = False + manufacturer: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class MedicationCatalogEntryEntity(BaseModel): + id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + medication_id: uuid.UUID + price: float | None = None + stock: int = 0 + is_available: bool = False + last_verified_at: datetime | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class InventoryRecordEntity(BaseModel): + id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + medication_id: uuid.UUID + stock: int = 0 + reserved_stock: int = 0 + available_stock: int = 0 + min_stock: int | None = None + max_stock: int | None = None + last_updated_at: datetime | None = None + source: str | None = None + batch_id: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class InventoryHistoryEntity(BaseModel): + id: uuid.UUID | None = None + inventory_record_id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + medication_id: uuid.UUID + previous_stock: int + new_stock: int + delta: int + reason: str | None = None + source: str | None = None + created_at: datetime | None = None + + +class ReservationEntity(BaseModel): + id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + medication_id: uuid.UUID + user_id: uuid.UUID | None = None + external_reference: str | None = None + quantity: int + status: ReservationStatus = ReservationStatus.PENDING + expires_at: datetime | None = None + confirmed_at: datetime | None = None + cancelled_at: datetime | None = None + idempotency_key: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class SyncJobEntity(BaseModel): + id: uuid.UUID | None = None + pharmacy_id: uuid.UUID + connector_id: uuid.UUID + strategy: SyncStrategy + status: SyncJobStatus = SyncJobStatus.PENDING + started_at: datetime | None = None + completed_at: datetime | None = None + items_total: int | None = None + items_processed: int | None = None + items_failed: int | None = None + error_message: str | None = None + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class SyncLogEntity(BaseModel): + id: uuid.UUID | None = None + sync_job_id: uuid.UUID + level: str = "INFO" + message: str + details: dict | None = None + created_at: datetime | None = None + + +class AuditLogEntity(BaseModel): + id: uuid.UUID | None = None + actor_id: uuid.UUID | None = None + actor_type: str | None = None + action: str + resource_type: str + resource_id: str | None = None + old_value: dict | None = None + new_value: dict | None = None + ip_address: str | None = None + user_agent: str | None = None + request_id: str | None = None + created_at: datetime | None = None + + +class TechnicalUserEntity(BaseModel): + id: uuid.UUID | None = None + username: str + email: str | None = None + full_name: str | None = None + role: UserRole = UserRole.VIEWER + is_active: bool = True + scopes: list[str] = Field(default_factory=list) + last_login_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class APIClientEntity(BaseModel): + id: uuid.UUID | None = None + name: str + description: str | None = None + owner_id: uuid.UUID | None = None + is_active: bool = True + scopes: list[str] = Field(default_factory=list) + rate_limit: int | None = None + allowed_ips: list[str] = Field(default_factory=list) + metadata_: dict | None = Field(default=None, alias="metadata") + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"populate_by_name": True} + + +class APIKeyEntity(BaseModel): + id: uuid.UUID | None = None + api_client_id: uuid.UUID + key_prefix: str + key_hash: bytes + name: str + is_active: bool = True + expires_at: datetime | None = None + last_used_at: datetime | None = None + created_at: datetime | None = None diff --git a/pip-platform/src/domain/interfaces/__init__.py b/pip-platform/src/domain/interfaces/__init__.py new file mode 100644 index 0000000..af807da --- /dev/null +++ b/pip-platform/src/domain/interfaces/__init__.py @@ -0,0 +1,35 @@ +from .repositories import ( + IAPIClientRepository, + IAPIKeyRepository, + IAuditLogRepository, + IConnectorConfigRepository, + IConnectorRepository, + IConnectorVersionRepository, + IERPSystemRepository, + IInventoryRepository, + IMedicationCatalogRepository, + IMedicationRepository, + IPharmacyRepository, + IReservationRepository, + ISyncJobRepository, + ISyncLogRepository, + ITechnicalUserRepository, +) + +__all__ = [ + "IAPIClientRepository", + "IAPIKeyRepository", + "IAuditLogRepository", + "IConnectorConfigRepository", + "IConnectorRepository", + "IConnectorVersionRepository", + "IERPSystemRepository", + "IInventoryRepository", + "IMedicationCatalogRepository", + "IMedicationRepository", + "IPharmacyRepository", + "IReservationRepository", + "ISyncJobRepository", + "ISyncLogRepository", + "ITechnicalUserRepository", +] diff --git a/pip-platform/src/domain/interfaces/repositories.py b/pip-platform/src/domain/interfaces/repositories.py new file mode 100644 index 0000000..9e94ba7 --- /dev/null +++ b/pip-platform/src/domain/interfaces/repositories.py @@ -0,0 +1,249 @@ +import uuid +from abc import ABC, abstractmethod +from collections.abc import Sequence +from typing import Any + +from src.domain.entities import ( + APIClientEntity, + APIKeyEntity, + AuditLogEntity, + ConnectorConfigEntity, + ConnectorEntity, + ConnectorVersionEntity, + ERPSystemEntity, + InventoryHistoryEntity, + InventoryRecordEntity, + MedicationCatalogEntryEntity, + MedicationEntity, + PharmacyEntity, + ReservationEntity, + SyncJobEntity, + SyncLogEntity, + TechnicalUserEntity, +) + + +class IPharmacyRepository(ABC): + @abstractmethod + async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None: ... + + @abstractmethod + async def get_by_code(self, code: str) -> PharmacyEntity | None: ... + + @abstractmethod + async def list_pharmacies( + self, + *, + status: str | None = None, + erp_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[PharmacyEntity], int]: ... + + @abstractmethod + async def create(self, entity: PharmacyEntity) -> PharmacyEntity: ... + + @abstractmethod + async def update(self, entity: PharmacyEntity) -> PharmacyEntity: ... + + @abstractmethod + async def delete(self, pharmacy_id: uuid.UUID) -> bool: ... + + +class IERPSystemRepository(ABC): + @abstractmethod + async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None: ... + + @abstractmethod + async def list_erp_systems(self, *, erp_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ERPSystemEntity], int]: ... + + @abstractmethod + async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity: ... + + @abstractmethod + async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity: ... + + @abstractmethod + async def delete(self, erp_id: uuid.UUID) -> bool: ... + + +class IConnectorRepository(ABC): + @abstractmethod + async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None: ... + + @abstractmethod + async def list_connectors(self, *, erp_system_id: uuid.UUID | None = None, pharmacy_id: uuid.UUID | None = None, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorEntity], int]: ... + + @abstractmethod + async def create(self, entity: ConnectorEntity) -> ConnectorEntity: ... + + @abstractmethod + async def update(self, entity: ConnectorEntity) -> ConnectorEntity: ... + + @abstractmethod + async def delete(self, connector_id: uuid.UUID) -> bool: ... + + +class IConnectorVersionRepository(ABC): + @abstractmethod + async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None: ... + + @abstractmethod + async def list_versions(self, *, connector_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorVersionEntity], int]: ... + + @abstractmethod + async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity: ... + + +class IConnectorConfigRepository(ABC): + @abstractmethod + async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]: ... + + @abstractmethod + async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None: ... + + @abstractmethod + async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: ... + + @abstractmethod + async def delete(self, config_id: uuid.UUID) -> bool: ... + + +class IMedicationRepository(ABC): + @abstractmethod + async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None: ... + + @abstractmethod + async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: ... + + @abstractmethod + async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]: ... + + @abstractmethod + async def create(self, entity: MedicationEntity) -> MedicationEntity: ... + + @abstractmethod + async def update(self, entity: MedicationEntity) -> MedicationEntity: ... + + @abstractmethod + async def delete(self, medication_id: uuid.UUID) -> bool: ... + + +class IMedicationCatalogRepository(ABC): + @abstractmethod + async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None: ... + + @abstractmethod + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]: ... + + @abstractmethod + async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: ... + + +class IInventoryRepository(ABC): + @abstractmethod + async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None: ... + + @abstractmethod + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]: ... + + @abstractmethod + async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: ... + + @abstractmethod + async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity: ... + + @abstractmethod + async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]: ... + + +class IReservationRepository(ABC): + @abstractmethod + async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None: ... + + @abstractmethod + async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None: ... + + @abstractmethod + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]: ... + + @abstractmethod + async def create(self, entity: ReservationEntity) -> ReservationEntity: ... + + @abstractmethod + async def update(self, entity: ReservationEntity) -> ReservationEntity: ... + + +class ISyncJobRepository(ABC): + @abstractmethod + async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None: ... + + @abstractmethod + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[SyncJobEntity], int]: ... + + @abstractmethod + async def create(self, entity: SyncJobEntity) -> SyncJobEntity: ... + + @abstractmethod + async def update(self, entity: SyncJobEntity) -> SyncJobEntity: ... + + +class ISyncLogRepository(ABC): + @abstractmethod + async def add(self, entity: SyncLogEntity) -> SyncLogEntity: ... + + @abstractmethod + async def list_by_job(self, job_id: uuid.UUID, *, level: str | None = None, offset: int = 0, limit: int = 100) -> Sequence[SyncLogEntity]: ... + + +class IAuditLogRepository(ABC): + @abstractmethod + async def add(self, entity: AuditLogEntity) -> AuditLogEntity: ... + + @abstractmethod + async def list_logs(self, *, actor_id: uuid.UUID | None = None, action: str | None = None, resource_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[AuditLogEntity], int]: ... + + +class ITechnicalUserRepository(ABC): + @abstractmethod + async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None: ... + + @abstractmethod + async def get_by_username(self, username: str) -> TechnicalUserEntity | None: ... + + @abstractmethod + async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]: ... + + @abstractmethod + async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity: ... + + @abstractmethod + async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity: ... + + +class IAPIClientRepository(ABC): + @abstractmethod + async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None: ... + + @abstractmethod + async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]: ... + + @abstractmethod + async def create(self, entity: APIClientEntity) -> APIClientEntity: ... + + @abstractmethod + async def update(self, entity: APIClientEntity) -> APIClientEntity: ... + + +class IAPIKeyRepository(ABC): + @abstractmethod + async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None: ... + + @abstractmethod + async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]: ... + + @abstractmethod + async def create(self, entity: APIKeyEntity) -> APIKeyEntity: ... + + @abstractmethod + async def update_last_used(self, key_id: uuid.UUID) -> None: ... diff --git a/pip-platform/src/domain/value_objects/__init__.py b/pip-platform/src/domain/value_objects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/infrastructure/__init__.py b/pip-platform/src/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/infrastructure/cache/__init__.py b/pip-platform/src/infrastructure/cache/__init__.py new file mode 100644 index 0000000..d7d155f --- /dev/null +++ b/pip-platform/src/infrastructure/cache/__init__.py @@ -0,0 +1,3 @@ +from .redis_cache import redis_cache + +__all__ = ["redis_cache"] diff --git a/pip-platform/src/infrastructure/cache/redis_cache.py b/pip-platform/src/infrastructure/cache/redis_cache.py new file mode 100644 index 0000000..1cf9bc9 --- /dev/null +++ b/pip-platform/src/infrastructure/cache/redis_cache.py @@ -0,0 +1,73 @@ +import json +from typing import Any + +import redis.asyncio as aioredis + +from src.infrastructure.config.settings import settings + + +class RedisCache: + def __init__(self) -> None: + self._client: aioredis.Redis | None = None + + async def connect(self) -> None: + self._client = aioredis.from_url( + settings.REDIS_URL, + decode_responses=True, + max_connections=20, + ) + + async def disconnect(self) -> None: + if self._client: + await self._client.close() + + @property + def client(self) -> aioredis.Redis: + if self._client is None: + raise RuntimeError("Redis client not connected. Call connect() first.") + return self._client + + async def get(self, key: str) -> Any | None: + data = await self.client.get(key) + if data is None: + return None + try: + return json.loads(data) + except (json.JSONDecodeError, TypeError): + return data + + async def set(self, key: str, value: Any, ttl: int | None = None) -> None: + ttl = ttl or settings.REDIS_CACHE_TTL + if isinstance(value, (dict, list, tuple)): + value = json.dumps(value) + await self.client.set(key, value, ex=ttl) + + async def delete(self, key: str) -> None: + await self.client.delete(key) + + async def exists(self, key: str) -> bool: + return bool(await self.client.exists(key)) + + async def get_with_lock(self, key: str, lock_timeout: int = 10) -> Any | None: + async with self.client.lock(f"{key}:lock", timeout=lock_timeout): + return await self.get(key) + + async def set_with_lock(self, key: str, value: Any, ttl: int | None = None, lock_timeout: int = 10) -> None: + async with self.client.lock(f"{key}:lock", timeout=lock_timeout): + await self.set(key, value, ttl) + + async def invalidate_pattern(self, pattern: str) -> None: + keys = [] + async for key in self.client.scan_iter(match=pattern): + keys.append(key) + if keys: + await self.client.delete(*keys) + + async def health_check(self) -> bool: + try: + return await self.client.ping() + except Exception: + return False + + +redis_cache = RedisCache() diff --git a/pip-platform/src/infrastructure/config/__init__.py b/pip-platform/src/infrastructure/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/infrastructure/config/settings.py b/pip-platform/src/infrastructure/config/settings.py new file mode 100644 index 0000000..2c2431d --- /dev/null +++ b/pip-platform/src/infrastructure/config/settings.py @@ -0,0 +1,58 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_nested_delimiter="__", + extra="ignore", + ) + + APP_NAME: str = "PIP - Pharmacy Integration Platform" + APP_VERSION: str = "0.1.0" + DEBUG: bool = False + + DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip" + DATABASE_POOL_SIZE: int = 20 + DATABASE_MAX_OVERFLOW: int = 10 + DATABASE_POOL_RECYCLE: int = 3600 + + REDIS_URL: str = "redis://localhost:6379/0" + REDIS_CACHE_TTL: int = 300 + + RABBITMQ_URL: str = "amqp://pip:pip@localhost:5672/pip" + + JWT_SECRET_KEY: str = "change-me-in-production" + JWT_ALGORITHM: str = "HS256" + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + JWT_ISSUER: str = "pip-platform" + + OAUTH2_TOKEN_URL: str = "/api/v1/auth/token" + + RATE_LIMIT_PER_MINUTE: int = 60 + RATE_LIMIT_AUTH_PER_MINUTE: int = 10 + + API_KEY_PREFIX: str = "pip" + API_KEY_LENGTH: int = 32 + + OTEL_EXPORTER_OTLP_ENDPOINT: str = "http://localhost:4317" + OTEL_SERVICE_NAME: str = "pip-platform" + OTEL_TRACES_ENABLED: bool = True + OTEL_METRICS_ENABLED: bool = True + + LOG_LEVEL: str = "INFO" + LOG_JSON_FORMAT: bool = True + + CORS_ORIGINS: list[str] = ["*"] + CORS_ALLOW_CREDENTIALS: bool = True + + HEALTH_CHECK_CACHE_TTL: int = 10 + + @property + def DATABASE_URL_SYNC(self) -> str: + return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1) + + +settings = Settings() diff --git a/pip-platform/src/infrastructure/database/__init__.py b/pip-platform/src/infrastructure/database/__init__.py new file mode 100644 index 0000000..0b2ac42 --- /dev/null +++ b/pip-platform/src/infrastructure/database/__init__.py @@ -0,0 +1,9 @@ +from .models import * +from .session import async_session_factory, engine, get_db_session, get_db_session_no_commit + +__all__ = [ + "async_session_factory", + "engine", + "get_db_session", + "get_db_session_no_commit", +] diff --git a/pip-platform/src/infrastructure/database/models/__init__.py b/pip-platform/src/infrastructure/database/models/__init__.py new file mode 100644 index 0000000..df347fd --- /dev/null +++ b/pip-platform/src/infrastructure/database/models/__init__.py @@ -0,0 +1,41 @@ +from .models import ( + APIClientModel, + APIKeyModel, + AuditLogModel, + Base, + BaseMixin, + ConnectorConfigModel, + ConnectorModel, + ConnectorVersionModel, + ERPSystemModel, + InventoryHistoryModel, + InventoryRecordModel, + MedicationCatalogEntryModel, + MedicationModel, + PharmacyModel, + ReservationModel, + SyncJobModel, + SyncLogModel, + TechnicalUserModel, +) + +__all__ = [ + "APIClientModel", + "APIKeyModel", + "AuditLogModel", + "Base", + "BaseMixin", + "ConnectorConfigModel", + "ConnectorModel", + "ConnectorVersionModel", + "ERPSystemModel", + "InventoryHistoryModel", + "InventoryRecordModel", + "MedicationCatalogEntryModel", + "MedicationModel", + "PharmacyModel", + "ReservationModel", + "SyncJobModel", + "SyncLogModel", + "TechnicalUserModel", +] diff --git a/pip-platform/src/infrastructure/database/models/models.py b/pip-platform/src/infrastructure/database/models/models.py new file mode 100644 index 0000000..059cfc7 --- /dev/null +++ b/pip-platform/src/infrastructure/database/models/models.py @@ -0,0 +1,349 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + Enum, + Float, + ForeignKey, + Index, + Integer, + LargeBinary, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class BaseMixin: + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + +class PharmacyModel(Base, BaseMixin): + __tablename__ = "pharmacies" + + code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + address: Mapped[str | None] = mapped_column(String(500)) + city: Mapped[str | None] = mapped_column(String(100)) + province: Mapped[str | None] = mapped_column(String(100)) + postal_code: Mapped[str | None] = mapped_column(String(20)) + country: Mapped[str] = mapped_column(String(2), default="ES") + latitude: Mapped[float | None] = mapped_column(Float) + longitude: Mapped[float | None] = mapped_column(Float) + phone: Mapped[str | None] = mapped_column(String(50)) + email: Mapped[str | None] = mapped_column(String(255)) + opening_hours: Mapped[dict | None] = mapped_column(JSONB) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + erp_type: Mapped[str | None] = mapped_column(String(50)) + erp_version: Mapped[str | None] = mapped_column(String(50)) + connector_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=True) + sync_strategy: Mapped[str] = mapped_column(String(20), default="incremental") + sync_interval_minutes: Mapped[int] = mapped_column(Integer, default=30) + last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_sync_status: Mapped[str | None] = mapped_column(String(20)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + connector = relationship("ConnectorModel", back_populates="pharmacies", foreign_keys=[connector_id]) + inventory_records = relationship("InventoryRecordModel", back_populates="pharmacy", cascade="all, delete-orphan") + reservations = relationship("ReservationModel", back_populates="pharmacy", cascade="all, delete-orphan") + catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="pharmacy", cascade="all, delete-orphan") + sync_jobs = relationship("SyncJobModel", back_populates="pharmacy", cascade="all, delete-orphan") + + __table_args__ = ( + Index("ix_pharmacies_location", "latitude", "longitude"), + Index("ix_pharmacies_status_erp", "status", "erp_type"), + ) + + +class ERPSystemModel(Base, BaseMixin): + __tablename__ = "erp_systems" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + erp_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + version: Mapped[str | None] = mapped_column(String(50)) + base_url: Mapped[str | None] = mapped_column(String(500)) + auth_config: Mapped[dict | None] = mapped_column(JSONB) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + connectors = relationship("ConnectorModel", back_populates="erp_system", cascade="all, delete-orphan") + + +class ConnectorModel(Base, BaseMixin): + __tablename__ = "connectors" + + erp_system_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("erp_systems.id"), nullable=False, index=True) + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + version: Mapped[str] = mapped_column(String(20), default="1.0.0") + config: Mapped[dict | None] = mapped_column(JSONB) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_error: Mapped[str | None] = mapped_column(Text) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + erp_system = relationship("ERPSystemModel", back_populates="connectors") + pharmacies = relationship("PharmacyModel", back_populates="connector", foreign_keys="PharmacyModel.connector_id") + configs = relationship("ConnectorConfigModel", back_populates="connector", cascade="all, delete-orphan") + connector_versions = relationship("ConnectorVersionModel", back_populates="connector", cascade="all, delete-orphan") + + __table_args__ = ( + UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"), + ) + + +class ConnectorVersionModel(Base, BaseMixin): + __tablename__ = "connector_versions" + + connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True) + connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + version: Mapped[str] = mapped_column(String(20), nullable=False) + changelog: Mapped[str | None] = mapped_column(Text) + download_url: Mapped[str | None] = mapped_column(String(500)) + checksum: Mapped[str | None] = mapped_column(String(128)) + is_latest: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + is_required: Mapped[bool] = mapped_column(Boolean, default=False) + released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + connector = relationship("ConnectorModel", back_populates="connector_versions") + + __table_args__ = ( + UniqueConstraint("connector_type", "version", name="uq_connector_version"), + ) + + +class ConnectorConfigModel(Base, BaseMixin): + __tablename__ = "connector_configs" + + connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True) + key: Mapped[str] = mapped_column(String(100), nullable=False) + value: Mapped[str] = mapped_column(Text, nullable=False) + encrypted: Mapped[bool] = mapped_column(Boolean, default=False) + description: Mapped[str | None] = mapped_column(String(500)) + + connector = relationship("ConnectorModel", back_populates="configs") + + __table_args__ = ( + UniqueConstraint("connector_id", "key", name="uq_connector_config_key"), + ) + + +class MedicationModel(Base, BaseMixin): + __tablename__ = "medications" + + nregistro: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(500), nullable=False, index=True) + active_ingredient: Mapped[str | None] = mapped_column(String(500)) + dosage: Mapped[str | None] = mapped_column(String(100)) + form: Mapped[str | None] = mapped_column(String(100)) + atc_code: Mapped[str | None] = mapped_column(String(20), index=True) + prescription_required: Mapped[bool] = mapped_column(Boolean, default=False) + manufacturer: Mapped[str | None] = mapped_column(String(255)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="medication", cascade="all, delete-orphan") + inventory_records = relationship("InventoryRecordModel", back_populates="medication", cascade="all, delete-orphan") + reservations = relationship("ReservationModel", back_populates="medication", cascade="all, delete-orphan") + + +class MedicationCatalogEntryModel(Base, BaseMixin): + __tablename__ = "medication_catalog" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True) + price: Mapped[float | None] = mapped_column(Float) + stock: Mapped[int] = mapped_column(Integer, default=0) + is_available: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + pharmacy = relationship("PharmacyModel", back_populates="catalog_entries") + medication = relationship("MedicationModel", back_populates="catalog_entries") + + __table_args__ = ( + UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"), + ) + + +class InventoryRecordModel(Base, BaseMixin): + __tablename__ = "inventory_records" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True) + stock: Mapped[int] = mapped_column(Integer, default=0) + reserved_stock: Mapped[int] = mapped_column(Integer, default=0) + available_stock: Mapped[int] = mapped_column(Integer, default=0) + min_stock: Mapped[int | None] = mapped_column(Integer) + max_stock: Mapped[int | None] = mapped_column(Integer) + last_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + source: Mapped[str | None] = mapped_column(String(100)) + batch_id: Mapped[str | None] = mapped_column(String(100)) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + pharmacy = relationship("PharmacyModel", back_populates="inventory_records") + medication = relationship("MedicationModel", back_populates="inventory_records") + history = relationship("InventoryHistoryModel", back_populates="inventory_record", cascade="all, delete-orphan") + + __table_args__ = ( + UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"), + ) + + +class InventoryHistoryModel(Base): + __tablename__ = "inventory_history" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + inventory_record_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("inventory_records.id"), nullable=False, index=True) + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True) + previous_stock: Mapped[int] = mapped_column(Integer, nullable=False) + new_stock: Mapped[int] = mapped_column(Integer, nullable=False) + delta: Mapped[int] = mapped_column(Integer, nullable=False) + reason: Mapped[str | None] = mapped_column(String(255)) + source: Mapped[str | None] = mapped_column(String(100)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + inventory_record = relationship("InventoryRecordModel", back_populates="history") + + +class ReservationModel(Base, BaseMixin): + __tablename__ = "reservations" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True) + user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True) + external_reference: Mapped[str | None] = mapped_column(String(255)) + quantity: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) + confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + idempotency_key: Mapped[str | None] = mapped_column(String(255), unique=True, index=True) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + pharmacy = relationship("PharmacyModel", back_populates="reservations") + medication = relationship("MedicationModel", back_populates="reservations") + user = relationship("TechnicalUserModel", back_populates="reservations") + + __table_args__ = ( + Index("ix_reservations_pharmacy_status", "pharmacy_id", "status"), + Index("ix_reservations_expires", "expires_at"), + ) + + +class SyncJobModel(Base, BaseMixin): + __tablename__ = "sync_jobs" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True) + connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True) + strategy: Mapped[str] = mapped_column(String(20), nullable=False) + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + items_total: Mapped[int | None] = mapped_column(Integer) + items_processed: Mapped[int | None] = mapped_column(Integer) + items_failed: Mapped[int | None] = mapped_column(Integer) + error_message: Mapped[str | None] = mapped_column(Text) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + pharmacy = relationship("PharmacyModel", back_populates="sync_jobs") + logs = relationship("SyncLogModel", back_populates="sync_job", cascade="all, delete-orphan") + + +class SyncLogModel(Base): + __tablename__ = "sync_logs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + sync_job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sync_jobs.id"), nullable=False, index=True) + level: Mapped[str] = mapped_column(String(20), default="INFO", nullable=False) + message: Mapped[str] = mapped_column(Text, nullable=False) + details: Mapped[dict | None] = mapped_column(JSONB) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + sync_job = relationship("SyncJobModel", back_populates="logs") + + +class AuditLogModel(Base): + __tablename__ = "audit_logs" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True) + actor_type: Mapped[str | None] = mapped_column(String(50)) + action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + resource_id: Mapped[str | None] = mapped_column(String(255), index=True) + old_value: Mapped[dict | None] = mapped_column(JSONB) + new_value: Mapped[dict | None] = mapped_column(JSONB) + ip_address: Mapped[str | None] = mapped_column(String(45)) + user_agent: Mapped[str | None] = mapped_column(String(500)) + request_id: Mapped[str | None] = mapped_column(String(100), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + __table_args__ = ( + Index("ix_audit_logs_action_resource", "action", "resource_type"), + Index("ix_audit_logs_actor_created", "actor_id", "created_at"), + ) + + +class TechnicalUserModel(Base, BaseMixin): + __tablename__ = "technical_users" + + username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + email: Mapped[str | None] = mapped_column(String(255)) + full_name: Mapped[str | None] = mapped_column(String(255)) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(20), default="viewer", index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + scopes: Mapped[dict | None] = mapped_column(JSONB, default=list) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + reservations = relationship("ReservationModel", back_populates="user") + api_clients = relationship("APIClientModel", back_populates="owner") + + +class APIClientModel(Base, BaseMixin): + __tablename__ = "api_clients" + + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + scopes: Mapped[dict | None] = mapped_column(JSONB, default=list) + rate_limit: Mapped[int | None] = mapped_column(Integer) + allowed_ips: Mapped[dict | None] = mapped_column(JSONB, default=list) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + + owner = relationship("TechnicalUserModel", back_populates="api_clients") + api_keys = relationship("APIKeyModel", back_populates="api_client", cascade="all, delete-orphan") + + +class APIKeyModel(Base): + __tablename__ = "api_keys" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + api_client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("api_clients.id"), nullable=False, index=True) + key_prefix: Mapped[str] = mapped_column(String(10), nullable=False, index=True) + key_hash: Mapped[bytes] = mapped_column(LargeBinary(64), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + api_client = relationship("APIClientModel", back_populates="api_keys") + + __table_args__ = ( + Index("ix_api_keys_prefix_active", "key_prefix", "is_active"), + ) diff --git a/pip-platform/src/infrastructure/database/repositories/__init__.py b/pip-platform/src/infrastructure/database/repositories/__init__.py new file mode 100644 index 0000000..cf87f26 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/__init__.py @@ -0,0 +1,32 @@ +from .pharmacy_repository import PharmacyRepository +from .technical_user_repository import TechnicalUserRepository +from .audit_log_repository import AuditLogRepository +from .api_client_repository import APIClientRepository, APIKeyRepository +from .connector_repository import ConnectorRepository +from .erp_system_repository import ERPSystemRepository +from .connector_config_repository import ConnectorConfigRepository +from .connector_version_repository import ConnectorVersionRepository +from .medication_repository import MedicationRepository +from .medication_catalog_repository import MedicationCatalogRepository +from .inventory_repository import InventoryRepository +from .reservation_repository import ReservationRepository +from .sync_job_repository import SyncJobRepository +from .sync_log_repository import SyncLogRepository + +__all__ = [ + "APIClientRepository", + "APIKeyRepository", + "AuditLogRepository", + "ConnectorConfigRepository", + "ConnectorRepository", + "ConnectorVersionRepository", + "ERPSystemRepository", + "InventoryRepository", + "MedicationCatalogRepository", + "MedicationRepository", + "PharmacyRepository", + "ReservationRepository", + "SyncJobRepository", + "SyncLogRepository", + "TechnicalUserRepository", +] diff --git a/pip-platform/src/infrastructure/database/repositories/api_client_repository.py b/pip-platform/src/infrastructure/database/repositories/api_client_repository.py new file mode 100644 index 0000000..88b8d8a --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/api_client_repository.py @@ -0,0 +1,116 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import APIClientEntity, APIKeyEntity +from src.domain.interfaces import IAPIClientRepository, IAPIKeyRepository +from src.infrastructure.database.models import APIClientModel, APIKeyModel + + +class APIClientRepository(IAPIClientRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None: + stmt = select(APIClientModel).where(APIClientModel.id == client_id) + result = await self._session.execute(stmt) + m = result.scalar_one_or_none() + if not m: + return None + return APIClientEntity( + id=m.id, name=m.name, description=m.description, owner_id=m.owner_id, + is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit, + allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at, + ) + + async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]: + filters: list[Any] = [] + if is_active is not None: + filters.append(APIClientModel.is_active == is_active) + count_stmt = select(func.count()).select_from(APIClientModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + stmt = select(APIClientModel).order_by(APIClientModel.name).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + entities = [ + APIClientEntity(id=m.id, name=m.name, description=m.description, owner_id=m.owner_id, + is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit, + allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at) + for m in models + ] + return entities, total + + async def create(self, entity: APIClientEntity) -> APIClientEntity: + model = APIClientModel( + name=entity.name, description=entity.description, owner_id=entity.owner_id, + is_active=entity.is_active, scopes=entity.scopes, rate_limit=entity.rate_limit, + allowed_ips=entity.allowed_ips, metadata_=entity.metadata_, + ) + self._session.add(model) + await self._session.flush() + return await self.get_by_id(model.id) # type: ignore[return-value] + + async def update(self, entity: APIClientEntity) -> APIClientEntity: + stmt = update(APIClientModel).where(APIClientModel.id == entity.id).values( + name=entity.name, description=entity.description, is_active=entity.is_active, + scopes=entity.scopes, rate_limit=entity.rate_limit, allowed_ips=entity.allowed_ips, + metadata_=entity.metadata_, + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + +class APIKeyRepository(IAPIKeyRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None: + stmt = select(APIKeyModel).where(APIKeyModel.key_prefix == key_prefix, APIKeyModel.is_active == True) # noqa: E712 + result = await self._session.execute(stmt) + m = result.scalar_one_or_none() + if not m: + return None + return APIKeyEntity( + id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix, + key_hash=m.key_hash, name=m.name, is_active=m.is_active, + expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at, + ) + + async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]: + stmt = select(APIKeyModel).where(APIKeyModel.api_client_id == api_client_id).order_by(APIKeyModel.created_at.desc()) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [ + APIKeyEntity( + id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix, + key_hash=m.key_hash, name=m.name, is_active=m.is_active, + expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at, + ) + for m in models + ] + + async def create(self, entity: APIKeyEntity) -> APIKeyEntity: + model = APIKeyModel( + api_client_id=entity.api_client_id, key_prefix=entity.key_prefix, + key_hash=entity.key_hash, name=entity.name, is_active=entity.is_active, + expires_at=entity.expires_at, + ) + self._session.add(model) + await self._session.flush() + return APIKeyEntity( + id=model.id, api_client_id=model.api_client_id, key_prefix=model.key_prefix, + key_hash=model.key_hash, name=model.name, is_active=model.is_active, + expires_at=model.expires_at, created_at=model.created_at, + ) + + async def update_last_used(self, key_id: uuid.UUID) -> None: + stmt = update(APIKeyModel).where(APIKeyModel.id == key_id).values(last_used_at=func.now()) + await self._session.execute(stmt) diff --git a/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py b/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py new file mode 100644 index 0000000..623d119 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/audit_log_repository.py @@ -0,0 +1,78 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import AuditLogEntity +from src.domain.interfaces import IAuditLogRepository +from src.infrastructure.database.models import AuditLogModel + + +def _model_to_entity(m: AuditLogModel) -> AuditLogEntity: + return AuditLogEntity( + id=m.id, + actor_id=m.actor_id, + actor_type=m.actor_type, + action=m.action, + resource_type=m.resource_type, + resource_id=m.resource_id, + old_value=m.old_value, + new_value=m.new_value, + ip_address=m.ip_address, + user_agent=m.user_agent, + request_id=m.request_id, + created_at=m.created_at, + ) + + +class AuditLogRepository(IAuditLogRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add(self, entity: AuditLogEntity) -> AuditLogEntity: + model = AuditLogModel( + actor_id=entity.actor_id, + actor_type=entity.actor_type, + action=entity.action, + resource_type=entity.resource_type, + resource_id=entity.resource_id, + old_value=entity.old_value, + new_value=entity.new_value, + ip_address=entity.ip_address, + user_agent=entity.user_agent, + request_id=entity.request_id, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def list_logs( + self, + *, + actor_id: uuid.UUID | None = None, + action: str | None = None, + resource_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[AuditLogEntity], int]: + filters: list[Any] = [] + if actor_id: + filters.append(AuditLogModel.actor_id == actor_id) + if action: + filters.append(AuditLogModel.action == action) + if resource_type: + filters.append(AuditLogModel.resource_type == resource_type) + + count_stmt = select(func.count()).select_from(AuditLogModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(AuditLogModel).order_by(AuditLogModel.created_at.desc()).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total diff --git a/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py new file mode 100644 index 0000000..efc704c --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/connector_config_repository.py @@ -0,0 +1,71 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ConnectorConfigEntity +from src.domain.interfaces import IConnectorConfigRepository +from src.infrastructure.database.models import ConnectorConfigModel + + +def _model_to_entity(m: ConnectorConfigModel) -> ConnectorConfigEntity: + return ConnectorConfigEntity( + id=m.id, + connector_id=m.connector_id, + key=m.key, + value=m.value, + encrypted=m.encrypted, + description=m.description, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +class ConnectorConfigRepository(IConnectorConfigRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]: + stmt = select(ConnectorConfigModel).where(ConnectorConfigModel.connector_id == connector_id).order_by(ConnectorConfigModel.key) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models] + + async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None: + stmt = select(ConnectorConfigModel).where( + ConnectorConfigModel.connector_id == connector_id, + ConnectorConfigModel.key == key, + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: + existing = await self.get_by_key(entity.connector_id, entity.key) + if existing: + stmt = ( + ConnectorConfigModel.__table__.update() + .where(ConnectorConfigModel.id == existing.id) + .values(value=entity.value, encrypted=entity.encrypted, description=entity.description) + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_key(entity.connector_id, entity.key) # type: ignore[return-value] + else: + model = ConnectorConfigModel( + connector_id=entity.connector_id, + key=entity.key, + value=entity.value, + encrypted=entity.encrypted, + description=entity.description, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def delete(self, config_id: uuid.UUID) -> bool: + stmt = delete(ConnectorConfigModel).where(ConnectorConfigModel.id == config_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/connector_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_repository.py new file mode 100644 index 0000000..6870dd1 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/connector_repository.py @@ -0,0 +1,101 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ConnectorEntity, ConnectorStatus +from src.domain.interfaces import IConnectorRepository +from src.infrastructure.database.models import ConnectorModel + + +def _model_to_entity(m: ConnectorModel) -> ConnectorEntity: + return ConnectorEntity( + id=m.id, + erp_system_id=m.erp_system_id, + pharmacy_id=m.pharmacy_id, + name=m.name, + connector_type=m.connector_type, + version=m.version, + config=m.config, + status=m.status, + last_heartbeat_at=m.last_heartbeat_at, + last_error=m.last_error, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: ConnectorEntity) -> dict[str, Any]: + return { + "erp_system_id": e.erp_system_id, + "pharmacy_id": e.pharmacy_id, + "name": e.name, + "connector_type": e.connector_type if isinstance(e.connector_type, str) else e.connector_type.value, + "version": e.version, + "config": e.config, + "status": e.status if isinstance(e.status, str) else e.status.value, + "last_heartbeat_at": e.last_heartbeat_at, + "last_error": e.last_error, + "metadata_": e.metadata_, + } + + +class ConnectorRepository(IConnectorRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None: + stmt = select(ConnectorModel).where(ConnectorModel.id == connector_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_connectors( + self, + *, + erp_system_id: uuid.UUID | None = None, + pharmacy_id: uuid.UUID | None = None, + status: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[ConnectorEntity], int]: + filters: list[Any] = [] + if erp_system_id: + filters.append(ConnectorModel.erp_system_id == erp_system_id) + if pharmacy_id: + filters.append(ConnectorModel.pharmacy_id == pharmacy_id) + if status: + filters.append(ConnectorModel.status == status) + + count_stmt = select(func.count()).select_from(ConnectorModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(ConnectorModel).order_by(ConnectorModel.name).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: ConnectorEntity) -> ConnectorEntity: + model = ConnectorModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: ConnectorEntity) -> ConnectorEntity: + data = _entity_to_model_data(entity) + stmt = update(ConnectorModel).where(ConnectorModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + async def delete(self, connector_id: uuid.UUID) -> bool: + stmt = delete(ConnectorModel).where(ConnectorModel.id == connector_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py b/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py new file mode 100644 index 0000000..5ac58d7 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/connector_version_repository.py @@ -0,0 +1,82 @@ +from collections.abc import Sequence +from typing import Any + +import uuid + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ConnectorVersionEntity +from src.domain.interfaces import IConnectorVersionRepository +from src.infrastructure.database.models import ConnectorVersionModel + + +def _model_to_entity(m: ConnectorVersionModel) -> ConnectorVersionEntity: + return ConnectorVersionEntity( + id=m.id, + connector_id=m.connector_id, + connector_type=m.connector_type, + version=m.version, + changelog=m.changelog, + download_url=m.download_url, + checksum=m.checksum, + is_latest=m.is_latest, + is_required=m.is_required, + released_at=m.released_at, + created_at=m.created_at, + ) + + +class ConnectorVersionRepository(IConnectorVersionRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None: + stmt = ( + select(ConnectorVersionModel) + .where(ConnectorVersionModel.connector_type == connector_type, ConnectorVersionModel.is_latest == True) # noqa: E712 + .order_by(ConnectorVersionModel.released_at.desc()) + .limit(1) + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_versions( + self, + *, + connector_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[ConnectorVersionEntity], int]: + filters: list[Any] = [] + if connector_type: + filters.append(ConnectorVersionModel.connector_type == connector_type) + + count_stmt = select(func.count()).select_from(ConnectorVersionModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(ConnectorVersionModel).order_by(ConnectorVersionModel.released_at.desc()).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity: + model = ConnectorVersionModel( + connector_id=entity.connector_id or uuid.uuid4(), + connector_type=entity.connector_type if isinstance(entity.connector_type, str) else entity.connector_type.value, + version=entity.version, + changelog=entity.changelog, + download_url=entity.download_url, + checksum=entity.checksum, + is_latest=entity.is_latest, + is_required=entity.is_required, + released_at=entity.released_at, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) diff --git a/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py b/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py new file mode 100644 index 0000000..9a406f6 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/erp_system_repository.py @@ -0,0 +1,89 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ERPSystemEntity +from src.domain.interfaces import IERPSystemRepository +from src.infrastructure.database.models import ERPSystemModel + + +def _model_to_entity(m: ERPSystemModel) -> ERPSystemEntity: + return ERPSystemEntity( + id=m.id, + name=m.name, + erp_type=m.erp_type, + version=m.version, + base_url=m.base_url, + auth_config=m.auth_config, + status=m.status, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: ERPSystemEntity) -> dict[str, Any]: + return { + "name": e.name, + "erp_type": e.erp_type if isinstance(e.erp_type, str) else e.erp_type.value, + "version": e.version, + "base_url": e.base_url, + "auth_config": e.auth_config, + "status": e.status if isinstance(e.status, str) else e.status.value, + "metadata_": e.metadata_, + } + + +class ERPSystemRepository(IERPSystemRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None: + stmt = select(ERPSystemModel).where(ERPSystemModel.id == erp_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_erp_systems( + self, + *, + erp_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[ERPSystemEntity], int]: + filters: list[Any] = [] + if erp_type: + filters.append(ERPSystemModel.erp_type == erp_type) + + count_stmt = select(func.count()).select_from(ERPSystemModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(ERPSystemModel).order_by(ERPSystemModel.name).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity: + model = ERPSystemModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity: + data = _entity_to_model_data(entity) + stmt = update(ERPSystemModel).where(ERPSystemModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + async def delete(self, erp_id: uuid.UUID) -> bool: + stmt = delete(ERPSystemModel).where(ERPSystemModel.id == erp_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/inventory_repository.py b/pip-platform/src/infrastructure/database/repositories/inventory_repository.py new file mode 100644 index 0000000..25e9545 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/inventory_repository.py @@ -0,0 +1,142 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import InventoryHistoryEntity, InventoryRecordEntity +from src.domain.interfaces import IInventoryRepository +from src.infrastructure.database.models import InventoryHistoryModel, InventoryRecordModel + + +def _record_model_to_entity(m: InventoryRecordModel) -> InventoryRecordEntity: + return InventoryRecordEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + medication_id=m.medication_id, + stock=m.stock, + reserved_stock=m.reserved_stock, + available_stock=m.available_stock, + min_stock=m.min_stock, + max_stock=m.max_stock, + last_updated_at=m.last_updated_at, + source=m.source, + batch_id=m.batch_id, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _record_entity_to_model_data(e: InventoryRecordEntity) -> dict[str, Any]: + return { + "pharmacy_id": e.pharmacy_id, + "medication_id": e.medication_id, + "stock": e.stock, + "reserved_stock": e.reserved_stock, + "available_stock": e.available_stock, + "min_stock": e.min_stock, + "max_stock": e.max_stock, + "last_updated_at": e.last_updated_at, + "source": e.source, + "batch_id": e.batch_id, + "metadata_": e.metadata_, + } + + +def _history_model_to_entity(m: InventoryHistoryModel) -> InventoryHistoryEntity: + return InventoryHistoryEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + medication_id=m.medication_id, + previous_stock=m.previous_stock, + new_stock=m.new_stock, + delta=m.delta, + reason=m.reason, + source=m.source, + created_at=m.created_at, + ) + + +class InventoryRepository(IInventoryRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None: + stmt = select(InventoryRecordModel).where( + InventoryRecordModel.pharmacy_id == pharmacy_id, + InventoryRecordModel.medication_id == medication_id, + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _record_model_to_entity(model) if model else None + + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]: + count_stmt = select(func.count()).select_from(InventoryRecordModel).where( + InventoryRecordModel.pharmacy_id == pharmacy_id + ) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(InventoryRecordModel) + .where(InventoryRecordModel.pharmacy_id == pharmacy_id) + .order_by(InventoryRecordModel.last_updated_at.desc().nullslast()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_record_model_to_entity(m) for m in models], total + + async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: + existing = await self.get_record(entity.pharmacy_id, entity.medication_id) + if existing: + data = _record_entity_to_model_data(entity) + stmt = ( + update(InventoryRecordModel) + .where( + InventoryRecordModel.pharmacy_id == entity.pharmacy_id, + InventoryRecordModel.medication_id == entity.medication_id, + ) + .values(**data) + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_record(entity.pharmacy_id, entity.medication_id) # type: ignore[return-value] + else: + model = InventoryRecordModel(**_record_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _record_model_to_entity(model) + + async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity: + model = InventoryHistoryModel( + inventory_record_id=entity.inventory_record_id, + pharmacy_id=entity.pharmacy_id, + medication_id=entity.medication_id, + previous_stock=entity.previous_stock, + new_stock=entity.new_stock, + delta=entity.delta, + reason=entity.reason, + source=entity.source, + ) + self._session.add(model) + await self._session.flush() + return _history_model_to_entity(model) + + async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]: + record = await self.get_record(pharmacy_id, medication_id) + if not record: + return [] + + stmt = ( + select(InventoryHistoryModel) + .where(InventoryHistoryModel.inventory_record_id == record.id) + .order_by(InventoryHistoryModel.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_history_model_to_entity(m) for m in models] diff --git a/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py b/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py new file mode 100644 index 0000000..f44a829 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/medication_catalog_repository.py @@ -0,0 +1,90 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import MedicationCatalogEntryEntity +from src.domain.interfaces import IMedicationCatalogRepository +from src.infrastructure.database.models import MedicationCatalogEntryModel + + +def _model_to_entity(m: MedicationCatalogEntryModel) -> MedicationCatalogEntryEntity: + return MedicationCatalogEntryEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + medication_id=m.medication_id, + price=m.price, + stock=m.stock, + is_available=m.is_available, + last_verified_at=m.last_verified_at, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: MedicationCatalogEntryEntity) -> dict[str, Any]: + return { + "pharmacy_id": e.pharmacy_id, + "medication_id": e.medication_id, + "price": e.price, + "stock": e.stock, + "is_available": e.is_available, + "last_verified_at": e.last_verified_at, + "metadata_": e.metadata_, + } + + +class MedicationCatalogRepository(IMedicationCatalogRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None: + stmt = select(MedicationCatalogEntryModel).where( + MedicationCatalogEntryModel.pharmacy_id == pharmacy_id, + MedicationCatalogEntryModel.medication_id == medication_id, + ) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]: + count_stmt = select(func.count()).select_from(MedicationCatalogEntryModel).where( + MedicationCatalogEntryModel.pharmacy_id == pharmacy_id + ) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(MedicationCatalogEntryModel) + .where(MedicationCatalogEntryModel.pharmacy_id == pharmacy_id) + .order_by(MedicationCatalogEntryModel.is_available.desc(), MedicationCatalogEntryModel.price.asc()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: + existing = await self.get_entry(entity.pharmacy_id, entity.medication_id) + if existing: + from sqlalchemy import update + data = _entity_to_model_data(entity) + stmt = ( + update(MedicationCatalogEntryModel) + .where( + MedicationCatalogEntryModel.pharmacy_id == entity.pharmacy_id, + MedicationCatalogEntryModel.medication_id == entity.medication_id, + ) + .values(**data) + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_entry(entity.pharmacy_id, entity.medication_id) # type: ignore[return-value] + else: + model = MedicationCatalogEntryModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) diff --git a/pip-platform/src/infrastructure/database/repositories/medication_repository.py b/pip-platform/src/infrastructure/database/repositories/medication_repository.py new file mode 100644 index 0000000..416bd54 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/medication_repository.py @@ -0,0 +1,101 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import MedicationEntity +from src.domain.interfaces import IMedicationRepository +from src.infrastructure.database.models import MedicationModel + + +def _model_to_entity(m: MedicationModel) -> MedicationEntity: + return MedicationEntity( + id=m.id, + nregistro=m.nregistro, + name=m.name, + active_ingredient=m.active_ingredient, + dosage=m.dosage, + form=m.form, + atc_code=m.atc_code, + prescription_required=m.prescription_required, + manufacturer=m.manufacturer, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: MedicationEntity) -> dict[str, Any]: + return { + "nregistro": e.nregistro, + "name": e.name, + "active_ingredient": e.active_ingredient, + "dosage": e.dosage, + "form": e.form, + "atc_code": e.atc_code, + "prescription_required": e.prescription_required, + "manufacturer": e.manufacturer, + "metadata_": e.metadata_, + } + + +class MedicationRepository(IMedicationRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None: + stmt = select(MedicationModel).where(MedicationModel.id == medication_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: + stmt = select(MedicationModel).where(MedicationModel.nregistro == nregistro) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]: + pattern = f"%{query}%" + filters = [ + or_( + MedicationModel.name.ilike(pattern), + MedicationModel.active_ingredient.ilike(pattern), + MedicationModel.nregistro.ilike(pattern), + MedicationModel.atc_code.ilike(pattern), + ) + ] + + count_stmt = select(func.count()).select_from(MedicationModel).where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(MedicationModel) + .where(*filters) + .order_by(MedicationModel.name) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: MedicationEntity) -> MedicationEntity: + model = MedicationModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: MedicationEntity) -> MedicationEntity: + data = _entity_to_model_data(entity) + stmt = update(MedicationModel).where(MedicationModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + async def delete(self, medication_id: uuid.UUID) -> bool: + stmt = delete(MedicationModel).where(MedicationModel.id == medication_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py b/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py new file mode 100644 index 0000000..692b6e5 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/pharmacy_repository.py @@ -0,0 +1,126 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import PharmacyEntity +from src.domain.interfaces import IPharmacyRepository +from src.infrastructure.database.models import PharmacyModel + + +def _model_to_entity(m: PharmacyModel) -> PharmacyEntity: + return PharmacyEntity( + id=m.id, + code=m.code, + name=m.name, + address=m.address, + city=m.city, + province=m.province, + postal_code=m.postal_code, + country=m.country, + latitude=m.latitude, + longitude=m.longitude, + phone=m.phone, + email=m.email, + opening_hours=m.opening_hours, + status=m.status, + erp_type=m.erp_type, + erp_version=m.erp_version, + connector_id=m.connector_id, + sync_strategy=m.sync_strategy, + sync_interval_minutes=m.sync_interval_minutes, + last_sync_at=m.last_sync_at, + last_sync_status=m.last_sync_status, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: PharmacyEntity) -> dict[str, Any]: + return { + "code": e.code, + "name": e.name, + "address": e.address, + "city": e.city, + "province": e.province, + "postal_code": e.postal_code, + "country": e.country, + "latitude": e.latitude, + "longitude": e.longitude, + "phone": e.phone, + "email": e.email, + "opening_hours": e.opening_hours, + "status": e.status, + "erp_type": e.erp_type, + "erp_version": e.erp_version, + "connector_id": e.connector_id, + "sync_strategy": e.sync_strategy, + "sync_interval_minutes": e.sync_interval_minutes, + "last_sync_at": e.last_sync_at, + "last_sync_status": e.last_sync_status, + "metadata_": e.metadata_, + } + + +class PharmacyRepository(IPharmacyRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None: + stmt = select(PharmacyModel).where(PharmacyModel.id == pharmacy_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def get_by_code(self, code: str) -> PharmacyEntity | None: + stmt = select(PharmacyModel).where(PharmacyModel.code == code) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_pharmacies( + self, + *, + status: str | None = None, + erp_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[PharmacyEntity], int]: + filters: list[Any] = [] + if status: + filters.append(PharmacyModel.status == status) + if erp_type: + filters.append(PharmacyModel.erp_type == erp_type) + + count_stmt = select(func.count()).select_from(PharmacyModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(PharmacyModel).order_by(PharmacyModel.name).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: PharmacyEntity) -> PharmacyEntity: + model = PharmacyModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: PharmacyEntity) -> PharmacyEntity: + data = _entity_to_model_data(entity) + stmt = update(PharmacyModel).where(PharmacyModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] + + async def delete(self, pharmacy_id: uuid.UUID) -> bool: + stmt = delete(PharmacyModel).where(PharmacyModel.id == pharmacy_id) + result = await self._session.execute(stmt) + return result.rowcount > 0 diff --git a/pip-platform/src/infrastructure/database/repositories/reservation_repository.py b/pip-platform/src/infrastructure/database/repositories/reservation_repository.py new file mode 100644 index 0000000..a4b637e --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/reservation_repository.py @@ -0,0 +1,94 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import ReservationEntity +from src.domain.interfaces import IReservationRepository +from src.infrastructure.database.models import ReservationModel + + +def _model_to_entity(m: ReservationModel) -> ReservationEntity: + return ReservationEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + medication_id=m.medication_id, + user_id=m.user_id, + external_reference=m.external_reference, + quantity=m.quantity, + status=m.status, + expires_at=m.expires_at, + confirmed_at=m.confirmed_at, + cancelled_at=m.cancelled_at, + idempotency_key=m.idempotency_key, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: ReservationEntity) -> dict[str, Any]: + return { + "pharmacy_id": e.pharmacy_id, + "medication_id": e.medication_id, + "user_id": e.user_id, + "external_reference": e.external_reference, + "quantity": e.quantity, + "status": e.status, + "expires_at": e.expires_at, + "confirmed_at": e.confirmed_at, + "cancelled_at": e.cancelled_at, + "idempotency_key": e.idempotency_key, + "metadata_": e.metadata_, + } + + +class ReservationRepository(IReservationRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None: + stmt = select(ReservationModel).where(ReservationModel.id == reservation_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None: + stmt = select(ReservationModel).where(ReservationModel.idempotency_key == key) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]: + filters: list[Any] = [ReservationModel.pharmacy_id == pharmacy_id] + if status: + filters.append(ReservationModel.status == status) + + count_stmt = select(func.count()).select_from(ReservationModel).where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(ReservationModel) + .where(*filters) + .order_by(ReservationModel.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: ReservationEntity) -> ReservationEntity: + model = ReservationModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: ReservationEntity) -> ReservationEntity: + data = _entity_to_model_data(entity) + stmt = update(ReservationModel).where(ReservationModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] diff --git a/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py b/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py new file mode 100644 index 0000000..6f583a9 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/sync_job_repository.py @@ -0,0 +1,95 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import SyncJobEntity, SyncJobStatus +from src.domain.interfaces import ISyncJobRepository +from src.infrastructure.database.models import SyncJobModel + + +def _model_to_entity(m: SyncJobModel) -> SyncJobEntity: + return SyncJobEntity( + id=m.id, + pharmacy_id=m.pharmacy_id, + connector_id=m.connector_id, + strategy=m.strategy, + status=m.status, + started_at=m.started_at, + completed_at=m.completed_at, + items_total=m.items_total, + items_processed=m.items_processed, + items_failed=m.items_failed, + error_message=m.error_message, + metadata_=m.metadata_, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +def _entity_to_model_data(e: SyncJobEntity) -> dict[str, Any]: + return { + "pharmacy_id": e.pharmacy_id, + "connector_id": e.connector_id, + "strategy": e.strategy, + "status": e.status, + "started_at": e.started_at, + "completed_at": e.completed_at, + "items_total": e.items_total, + "items_processed": e.items_processed, + "items_failed": e.items_failed, + "error_message": e.error_message, + "metadata_": e.metadata_, + } + + +class SyncJobRepository(ISyncJobRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None: + stmt = select(SyncJobModel).where(SyncJobModel.id == job_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_by_pharmacy( + self, + pharmacy_id: uuid.UUID, + *, + status: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[Sequence[SyncJobEntity], int]: + filters: list[Any] = [SyncJobModel.pharmacy_id == pharmacy_id] + if status: + filters.append(SyncJobModel.status == status) + + count_stmt = select(func.count()).select_from(SyncJobModel).where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = ( + select(SyncJobModel) + .where(*filters) + .order_by(SyncJobModel.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: SyncJobEntity) -> SyncJobEntity: + model = SyncJobModel(**_entity_to_model_data(entity)) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: SyncJobEntity) -> SyncJobEntity: + data = _entity_to_model_data(entity) + stmt = update(SyncJobModel).where(SyncJobModel.id == entity.id).values(**data) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] diff --git a/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py b/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py new file mode 100644 index 0000000..f99d19b --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/sync_log_repository.py @@ -0,0 +1,53 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import SyncLogEntity +from src.domain.interfaces import ISyncLogRepository +from src.infrastructure.database.models import SyncLogModel + + +def _model_to_entity(m: SyncLogModel) -> SyncLogEntity: + return SyncLogEntity( + id=m.id, + sync_job_id=m.sync_job_id, + level=m.level, + message=m.message, + details=m.details, + created_at=m.created_at, + ) + + +class SyncLogRepository(ISyncLogRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def add(self, entity: SyncLogEntity) -> SyncLogEntity: + model = SyncLogModel( + sync_job_id=entity.sync_job_id, + level=entity.level, + message=entity.message, + details=entity.details, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def list_by_job( + self, + job_id: uuid.UUID, + *, + level: str | None = None, + offset: int = 0, + limit: int = 100, + ) -> Sequence[SyncLogEntity]: + stmt = select(SyncLogModel).where(SyncLogModel.sync_job_id == job_id) + if level: + stmt = stmt.where(SyncLogModel.level == level) + stmt = stmt.order_by(SyncLogModel.created_at.desc()).offset(offset).limit(limit) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models] diff --git a/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py b/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py new file mode 100644 index 0000000..59ffda6 --- /dev/null +++ b/pip-platform/src/infrastructure/database/repositories/technical_user_repository.py @@ -0,0 +1,88 @@ +import uuid +from collections.abc import Sequence +from typing import Any + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.entities import TechnicalUserEntity +from src.domain.interfaces import ITechnicalUserRepository +from src.infrastructure.database.models import TechnicalUserModel + + +def _model_to_entity(m: TechnicalUserModel) -> TechnicalUserEntity: + return TechnicalUserEntity( + id=m.id, + username=m.username, + email=m.email, + full_name=m.full_name, + role=m.role, + is_active=m.is_active, + scopes=m.scopes or [], + last_login_at=m.last_login_at, + created_at=m.created_at, + updated_at=m.updated_at, + ) + + +class TechnicalUserRepository(ITechnicalUserRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None: + stmt = select(TechnicalUserModel).where(TechnicalUserModel.id == user_id) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def get_by_username(self, username: str) -> TechnicalUserEntity | None: + stmt = select(TechnicalUserModel).where(TechnicalUserModel.username == username) + result = await self._session.execute(stmt) + model = result.scalar_one_or_none() + return _model_to_entity(model) if model else None + + async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]: + filters: list[Any] = [] + if role: + filters.append(TechnicalUserModel.role == role) + if is_active is not None: + filters.append(TechnicalUserModel.is_active == is_active) + + count_stmt = select(func.count()).select_from(TechnicalUserModel) + if filters: + count_stmt = count_stmt.where(*filters) + total = (await self._session.execute(count_stmt)).scalar() or 0 + + stmt = select(TechnicalUserModel).order_by(TechnicalUserModel.username).offset(offset).limit(limit) + if filters: + stmt = stmt.where(*filters) + result = await self._session.execute(stmt) + models = result.scalars().all() + return [_model_to_entity(m) for m in models], total + + async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity: + model = TechnicalUserModel( + username=entity.username, + email=entity.email, + full_name=entity.full_name, + password_hash=password_hash, + role=entity.role, + is_active=entity.is_active, + scopes=entity.scopes, + ) + self._session.add(model) + await self._session.flush() + return _model_to_entity(model) + + async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity: + stmt = update(TechnicalUserModel).where(TechnicalUserModel.id == entity.id).values( + email=entity.email, + full_name=entity.full_name, + role=entity.role, + is_active=entity.is_active, + scopes=entity.scopes, + last_login_at=entity.last_login_at, + ) + await self._session.execute(stmt) + await self._session.flush() + return await self.get_by_id(entity.id) # type: ignore[return-value] diff --git a/pip-platform/src/infrastructure/database/session.py b/pip-platform/src/infrastructure/database/session.py new file mode 100644 index 0000000..a545bb3 --- /dev/null +++ b/pip-platform/src/infrastructure/database/session.py @@ -0,0 +1,40 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from src.infrastructure.config.settings import settings + +engine = create_async_engine( + settings.DATABASE_URL, + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_recycle=settings.DATABASE_POOL_RECYCLE, + echo=settings.DEBUG, + pool_pre_ping=True, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def get_db_session_no_commit() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + try: + yield session + finally: + await session.close() diff --git a/pip-platform/src/infrastructure/messaging/__init__.py b/pip-platform/src/infrastructure/messaging/__init__.py new file mode 100644 index 0000000..fa0dc23 --- /dev/null +++ b/pip-platform/src/infrastructure/messaging/__init__.py @@ -0,0 +1,3 @@ +from .rabbitmq import rabbitmq_client + +__all__ = ["rabbitmq_client"] diff --git a/pip-platform/src/infrastructure/messaging/rabbitmq.py b/pip-platform/src/infrastructure/messaging/rabbitmq.py new file mode 100644 index 0000000..53453c3 --- /dev/null +++ b/pip-platform/src/infrastructure/messaging/rabbitmq.py @@ -0,0 +1,131 @@ +import json +import logging +from typing import Any + +import aio_pika +from aio_pika import ExchangeType, Message, RobustConnection + +from src.infrastructure.config.settings import settings + +logger = logging.getLogger(__name__) + + +class RabbitMQClient: + PIP_EXCHANGE = "pip.events" + PIP_DLX = "pip.dlx" + + ROUTING_KEYS = { + "inventory_updated": "inventory.updated", + "reservation_created": "reservation.created", + "reservation_confirmed": "reservation.confirmed", + "reservation_cancelled": "reservation.cancelled", + "reservation_expired": "reservation.expired", + "sync_started": "sync.started", + "sync_completed": "sync.completed", + "sync_failed": "sync.failed", + "sync_cancelled": "sync.cancelled", + "sync_created": "sync.created", + "connector_heartbeat": "connector.heartbeat", + "connector_error": "connector.error", + "connector_created": "connector.created", + "connector_updated": "connector.updated", + "connector_deleted": "connector.deleted", + "connector_deployed": "connector.deployed", + "connector_deactivated": "connector.deactivated", + "pharmacy_updated": "pharmacy.updated", + "medication_created": "medication.created", + "medication_updated": "medication.updated", + "medication_deleted": "medication.deleted", + "catalog_upserted": "catalog.upserted", + } + + def __init__(self) -> None: + self._connection: RobustConnection | None = None + self._channel: aio_pika.RobustChannel | None = None + self._exchange: aio_pika.RobustExchange | None = None + self._dlx: aio_pika.RobustExchange | None = None + + async def connect(self) -> None: + self._connection = await aio_pika.connect_robust(settings.RABBITMQ_URL) + self._channel = await self._connection.channel() + await self._channel.set_qos(prefetch_count=100) + + self._exchange = await self._channel.declare_exchange( + self.PIP_EXCHANGE, + ExchangeType.TOPIC, + durable=True, + ) + + self._dlx = await self._channel.declare_exchange( + self.PIP_DLX, + ExchangeType.TOPIC, + durable=True, + ) + + logger.info("rabbitmq_connected", extra={"url": settings.RABBITMQ_URL}) + + async def disconnect(self) -> None: + if self._connection: + await self._connection.close() + + async def publish(self, routing_key: str, payload: dict[str, Any], headers: dict[str, str] | None = None) -> None: + if not self._exchange: + raise RuntimeError("RabbitMQ not connected. Call connect() first.") + + message = Message( + body=json.dumps(payload, default=str).encode(), + content_type="application/json", + delivery_mode=aio_pika.DeliveryMode.PERSISTENT, + headers=headers or {}, + ) + + await self._exchange.publish(message, routing_key=routing_key) + + logger.debug( + "message_published", + extra={"routing_key": routing_key}, + ) + + async def consume( + self, + queue_name: str, + routing_keys: list[str], + callback: Any, + dlq: bool = True, + ) -> aio_pika.RobustQueue: + if not self._channel or not self._exchange: + raise RuntimeError("RabbitMQ not connected. Call connect() first.") + + queue_arguments = { + "x-dead-letter-exchange": self.PIP_DLX, + "x-dead-letter-routing-key": queue_name, + } + + queue = await self._channel.declare_queue( + queue_name, + durable=True, + arguments=queue_arguments, + ) + + for routing_key in routing_keys: + await queue.bind(self._exchange, routing_key=routing_key) + + if dlq and self._dlx: + dlq_name = f"{queue_name}.dlq" + dlq = await self._channel.declare_queue(dlq_name, durable=True) + await dlq.bind(self._dlx, routing_key=queue_name) + + await queue.consume(callback) + + return queue + + async def health_check(self) -> bool: + try: + if not self._connection or self._connection.is_closed: + return False + return True + except Exception: + return False + + +rabbitmq_client = RabbitMQClient() diff --git a/pip-platform/src/main.py b/pip-platform/src/main.py new file mode 100644 index 0000000..f0ab911 --- /dev/null +++ b/pip-platform/src/main.py @@ -0,0 +1,78 @@ +from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from prometheus_client import make_asgi_app + +from src.core.exceptions import PIPException +from src.core.logging import configure_logging +from src.core.metrics import metrics_middleware +from src.core.tracing import setup_tracing +from src.infrastructure.cache.redis_cache import redis_cache +from src.infrastructure.config.settings import settings +from src.infrastructure.messaging.rabbitmq import rabbitmq_client +from src.api.middleware.audit import AuditMiddleware +from src.api.middleware.logging import RequestLoggingMiddleware +from src.api.middleware.rate_limit import RateLimitMiddleware +from src.api.v1.router import v1_router +from src.infrastructure.database.session import engine + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + configure_logging() + setup_tracing(app, engine=engine) + + await redis_cache.connect() + await rabbitmq_client.connect() + + yield + + await rabbitmq_client.disconnect() + await redis_cache.disconnect() + await engine.dispose() + + +def create_app() -> FastAPI: + application = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications", + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + lifespan=lifespan, + ) + + application.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=["*"], + allow_headers=["*"], + ) + + application.add_middleware(RateLimitMiddleware) + application.add_middleware(AuditMiddleware) + application.add_middleware(RequestLoggingMiddleware) + application.middleware("http")(metrics_middleware) + + application.include_router(v1_router) + + metrics_app = make_asgi_app() + application.mount("/metrics", metrics_app) + + @application.exception_handler(PIPException) + async def pip_exception_handler(request: Request, exc: PIPException) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content=exc.to_dict()) + + @application.exception_handler(Exception) + async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: + return JSONResponse(status_code=500, content={"error": {"code": "INTERNAL_ERROR", "message": "Internal server error"}}) + + return application + + +app = create_app() diff --git a/pip-platform/src/services/__init__.py b/pip-platform/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/audit/__init__.py b/pip-platform/src/services/audit/__init__.py new file mode 100644 index 0000000..bfe0b78 --- /dev/null +++ b/pip-platform/src/services/audit/__init__.py @@ -0,0 +1,3 @@ +from .audit_service import AuditService + +__all__ = ["AuditService"] diff --git a/pip-platform/src/services/audit/audit_service.py b/pip-platform/src/services/audit/audit_service.py new file mode 100644 index 0000000..c08cd52 --- /dev/null +++ b/pip-platform/src/services/audit/audit_service.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.domain.entities import AuditLogEntity +from src.domain.interfaces import IAuditLogRepository +from src.core.logging import get_logger + +logger = get_logger("audit_service") + + +class AuditService: + def __init__(self, audit_repo: IAuditLogRepository) -> None: + self._audit_repo = audit_repo + + async def log_action( + self, + action: str, + resource_type: str, + *, + actor_id: UUID | None = None, + actor_type: str | None = None, + resource_id: str | None = None, + old_value: dict | None = None, + new_value: dict | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + request_id: str | None = None, + ) -> AuditLogEntity: + entity = AuditLogEntity( + actor_id=actor_id, + actor_type=actor_type, + action=action, + resource_type=resource_type, + resource_id=resource_id, + old_value=old_value, + new_value=new_value, + ip_address=ip_address, + user_agent=user_agent, + request_id=request_id, + ) + created = await self._audit_repo.add(entity) + return created + + async def list_logs( + self, + *, + actor_id: UUID | None = None, + action: str | None = None, + resource_type: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[AuditLogEntity], int]: + return await self._audit_repo.list_logs( + actor_id=actor_id, + action=action, + resource_type=resource_type, + offset=offset, + limit=limit, + ) diff --git a/pip-platform/src/services/auth/__init__.py b/pip-platform/src/services/auth/__init__.py new file mode 100644 index 0000000..758c722 --- /dev/null +++ b/pip-platform/src/services/auth/__init__.py @@ -0,0 +1,27 @@ +from .security import ( + RBACGuard, + TokenData, + create_access_token, + create_refresh_token, + decode_token, + generate_api_key, + hash_api_key, + hash_password, + validate_token_type, + verify_api_key, + verify_password, +) + +__all__ = [ + "RBACGuard", + "TokenData", + "create_access_token", + "create_refresh_token", + "decode_token", + "generate_api_key", + "hash_api_key", + "hash_password", + "validate_token_type", + "verify_api_key", + "verify_password", +] diff --git a/pip-platform/src/services/auth/auth_service.py b/pip-platform/src/services/auth/auth_service.py new file mode 100644 index 0000000..ccaf689 --- /dev/null +++ b/pip-platform/src/services/auth/auth_service.py @@ -0,0 +1,102 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.exceptions import ConflictException, UnauthorizedException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import TechnicalUserEntity, UserRole +from src.infrastructure.database.repositories import TechnicalUserRepository +from src.services.auth.security import ( + RBACGuard, + TokenData, + create_access_token, + create_refresh_token, + decode_token, + hash_password, + validate_token_type, + verify_password, +) + +logger = get_logger("auth_service") + + +class AuthService: + def __init__(self, session: AsyncSession) -> None: + self._user_repo = TechnicalUserRepository(session) + self._session = session + + async def register(self, username: str, password: str, email: str | None = None, full_name: str | None = None, role: UserRole = UserRole.VIEWER) -> TechnicalUserEntity: + if not username or len(username) < 3: + raise ValidationException("Username must be at least 3 characters") + if not password or len(password) < 8: + raise ValidationException("Password must be at least 8 characters") + + existing = await self._user_repo.get_by_username(username) + if existing: + raise ConflictException(f"Username '{username}' already exists") + + pw_hash = hash_password(password) + entity = TechnicalUserEntity( + username=username, + email=email, + full_name=full_name, + role=role, + is_active=True, + scopes=self._default_scopes_for_role(role), + ) + user = await self._user_repo.create(entity, pw_hash) + logger.info("user_registered", extra={"username": username, "role": role.value}) + return user + + async def authenticate(self, username: str, password: str) -> tuple[TechnicalUserEntity, str, str]: + user = await self._user_repo.get_by_username(username) + if not user: + raise UnauthorizedException("Invalid credentials") + if not user.is_active: + raise UnauthorizedException("Account is disabled") + + from src.infrastructure.database.models import TechnicalUserModel + from sqlalchemy import select + stmt = select(TechnicalUserModel.password_hash).where(TechnicalUserModel.id == user.id) + result = await self._session.execute(stmt) + pw_hash = result.scalar_one_or_none() + if not pw_hash or not verify_password(password, pw_hash): + raise UnauthorizedException("Invalid credentials") + + user.last_login_at = datetime.now(timezone.utc) + await self._user_repo.update(user) + + access_token = create_access_token(subject=user.id, role=user.role, scopes=user.scopes) + refresh_token = create_refresh_token(subject=user.id) + + logger.info("user_authenticated", extra={"username": username}) + return user, access_token, refresh_token + + async def refresh_access_token(self, refresh_token: str) -> tuple[str, str]: + payload = decode_token(refresh_token) + validate_token_type(payload, expected_type="refresh") + + user_id = uuid.UUID(payload["sub"]) + user = await self._user_repo.get_by_id(user_id) + if not user or not user.is_active: + raise UnauthorizedException("User not found or inactive") + + new_access = create_access_token(subject=user.id, role=user.role, scopes=user.scopes) + new_refresh = create_refresh_token(subject=user.id) + return new_access, new_refresh + + async def validate_access_token(self, token: str) -> TokenData: + payload = decode_token(token) + validate_token_type(payload, expected_type="access") + return TokenData(payload) + + def _default_scopes_for_role(self, role: UserRole) -> list[str]: + scopes_map: dict[UserRole, list[str]] = { + UserRole.SUPERADMIN: ["*"], + UserRole.ADMIN: ["pharmacies:read", "pharmacies:write", "medications:read", "medications:write", "inventory:read", "inventory:write", "connectors:read", "connectors:write", "sync:read", "sync:write", "reservations:read", "reservations:write", "audit:read", "users:read", "users:write", "api-keys:read", "api-keys:write"], + UserRole.OPERATOR: ["pharmacies:read", "pharmacies:write", "medications:read", "inventory:read", "inventory:write", "connectors:read", "sync:read", "sync:write", "reservations:read", "reservations:write"], + UserRole.VIEWER: ["pharmacies:read", "medications:read", "inventory:read", "reservations:read"], + UserRole.API_CLIENT: ["pharmacies:read", "medications:read", "inventory:read", "reservations:read", "reservations:write"], + } + return scopes_map.get(role, ["pharmacies:read", "medications:read", "inventory:read"]) diff --git a/pip-platform/src/services/auth/security.py b/pip-platform/src/services/auth/security.py new file mode 100644 index 0000000..50458eb --- /dev/null +++ b/pip-platform/src/services/auth/security.py @@ -0,0 +1,126 @@ +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any +from uuid import UUID, uuid4 + +from jose import JWTError, jwt + +from src.core.exceptions import ForbiddenException, UnauthorizedException +from src.domain.entities import UserRole +from src.infrastructure.config.settings import settings + + +def hash_password(password: str) -> str: + import bcrypt as _bcrypt + return _bcrypt.hashpw(password.encode("utf-8"), _bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + import bcrypt as _bcrypt + return _bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + + +def create_access_token(subject: str | UUID, role: UserRole, scopes: list[str], extra_claims: dict | None = None) -> str: + now = datetime.now(timezone.utc) + expires = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + claims: dict[str, Any] = { + "sub": str(subject), + "role": role.value, + "scopes": scopes, + "iss": settings.JWT_ISSUER, + "iat": now, + "exp": expires, + "type": "access", + "jti": str(uuid4()), + } + if extra_claims: + claims.update(extra_claims) + return jwt.encode(claims, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def create_refresh_token(subject: str | UUID) -> str: + now = datetime.now(timezone.utc) + expires = now + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS) + claims: dict[str, Any] = { + "sub": str(subject), + "iss": settings.JWT_ISSUER, + "iat": now, + "exp": expires, + "type": "refresh", + "jti": str(uuid4()), + } + return jwt.encode(claims, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def decode_token(token: str) -> dict[str, Any]: + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM], issuer=settings.JWT_ISSUER) + return payload + except JWTError as e: + raise UnauthorizedException(message=f"Invalid token: {e}") from e + + +def validate_token_type(payload: dict[str, Any], expected_type: str = "access") -> dict[str, Any]: + if payload.get("type") != expected_type: + raise UnauthorizedException(message=f"Expected {expected_type} token, got {payload.get('type')}") + return payload + + +class TokenData: + def __init__(self, payload: dict[str, Any]): + self.subject: str = payload.get("sub", "") + self.role: UserRole = UserRole(payload.get("role", "viewer")) + self.scopes: list[str] = payload.get("scopes", []) + self.issuer: str = payload.get("iss", "") + self.token_type: str = payload.get("type", "access") + self.jti: str = payload.get("jti", "") + self.exp: datetime | None = None + if exp := payload.get("exp"): + self.exp = datetime.fromtimestamp(exp, tz=timezone.utc) + + +class RBACGuard: + ROLE_HIERARCHY: dict[UserRole, set[UserRole]] = { + UserRole.SUPERADMIN: {UserRole.SUPERADMIN, UserRole.ADMIN, UserRole.OPERATOR, UserRole.VIEWER, UserRole.API_CLIENT}, + UserRole.ADMIN: {UserRole.ADMIN, UserRole.OPERATOR, UserRole.VIEWER, UserRole.API_CLIENT}, + UserRole.OPERATOR: {UserRole.OPERATOR, UserRole.VIEWER}, + UserRole.VIEWER: {UserRole.VIEWER}, + UserRole.API_CLIENT: {UserRole.API_CLIENT, UserRole.VIEWER}, + } + + @classmethod + def has_role(cls, token_data: TokenData, required_role: UserRole) -> bool: + allowed = cls.ROLE_HIERARCHY.get(token_data.role, set()) + return required_role in allowed + + @classmethod + def require_role(cls, token_data: TokenData, required_role: UserRole) -> None: + if not cls.has_role(token_data, required_role): + raise ForbiddenException(required_role=required_role.value) + + @classmethod + def has_scope(cls, token_data: TokenData, required_scope: str) -> bool: + if "*" in token_data.scopes: + return True + return required_scope in token_data.scopes + + @classmethod + def require_scope(cls, token_data: TokenData, required_scope: str) -> None: + if not cls.has_scope(token_data, required_scope): + raise ForbiddenException(message=f"Scope '{required_scope}' required") + + +def generate_api_key() -> tuple[str, str, str]: + raw_key = secrets.token_urlsafe(settings.API_KEY_LENGTH) + prefix = raw_key[:8] + key_hash = hashlib.sha256(raw_key.encode()).digest() + return raw_key, prefix, key_hash + + +def hash_api_key(raw_key: str) -> bytes: + return hashlib.sha256(raw_key.encode()).digest() + + +def verify_api_key(raw_key: str, stored_hash: bytes) -> bool: + return hashlib.sha256(raw_key.encode()).digest() == stored_hash diff --git a/pip-platform/src/services/auth/user_service.py b/pip-platform/src/services/auth/user_service.py new file mode 100644 index 0000000..7ce033d --- /dev/null +++ b/pip-platform/src/services/auth/user_service.py @@ -0,0 +1,71 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.core.exceptions import ConflictException, ForbiddenException, NotFoundException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import TechnicalUserEntity, UserRole +from src.infrastructure.database.models import TechnicalUserModel +from src.infrastructure.database.repositories import TechnicalUserRepository +from src.services.auth.security import RBACGuard, TokenData, hash_password, verify_password + +logger = get_logger("user_service") + + +class UserService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._repo = TechnicalUserRepository(session) + + async def get_user(self, user_id: uuid.UUID) -> TechnicalUserEntity: + user = await self._repo.get_by_id(user_id) + if not user: + raise NotFoundException("User", str(user_id)) + return user + + async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50, caller: TokenData | None = None) -> tuple[list[TechnicalUserEntity], int]: + if caller and not RBACGuard.has_scope(caller, "users:read"): + raise ForbiddenException("Scope 'users:read' required") + return await self._repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit) + + async def create_user(self, username: str, password: str, email: str | None = None, full_name: str | None = None, role: UserRole = UserRole.VIEWER, caller: TokenData | None = None) -> TechnicalUserEntity: + if caller: + RBACGuard.require_role(caller, UserRole.ADMIN) + existing = await self._repo.get_by_username(username) + if existing: + raise ConflictException(f"Username '{username}' already exists") + pw_hash = hash_password(password) + entity = TechnicalUserEntity(username=username, email=email, full_name=full_name, role=role, is_active=True, scopes=[]) + user = await self._repo.create(entity, pw_hash) + logger.info("user_created", extra={"username": username, "role": role.value, "created_by": caller.subject if caller else "system"}) + return user + + async def update_user(self, user_id: uuid.UUID, *, email: str | None = None, full_name: str | None = None, role: UserRole | None = None, is_active: bool | None = None, caller: TokenData | None = None) -> TechnicalUserEntity: + if caller: + RBACGuard.require_role(caller, UserRole.ADMIN) + user = await self.get_user(user_id) + if email is not None: + user.email = email + if full_name is not None: + user.full_name = full_name + if role is not None: + user.role = role + if is_active is not None: + user.is_active = is_active + user = await self._repo.update(user) + logger.info("user_updated", extra={"user_id": str(user_id), "updated_by": caller.subject if caller else "system"}) + return user + + async def change_password(self, user_id: uuid.UUID, current_password: str, new_password: str, caller: TokenData | None = None) -> bool: + stmt = select(TechnicalUserModel.password_hash).where(TechnicalUserModel.id == user_id) + result = await self._session.execute(stmt) + pw_hash = result.scalar_one_or_none() + if not pw_hash or not verify_password(current_password, pw_hash): + raise ValidationException("Current password is incorrect") + new_hash = hash_password(new_password) + stmt = update(TechnicalUserModel).where(TechnicalUserModel.id == user_id).values(password_hash=new_hash) + await self._session.execute(stmt) + logger.info("password_changed", extra={"user_id": str(user_id)}) + return True diff --git a/pip-platform/src/services/connector/__init__.py b/pip-platform/src/services/connector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/connector_service.py b/pip-platform/src/services/connector_service.py new file mode 100644 index 0000000..6518faa --- /dev/null +++ b/pip-platform/src/services/connector_service.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.connectors.base import ConnectorResult +from src.connectors.registry import connector_registry +from src.connectors.sdk.context import ConnectorContext +from src.core.exceptions import ConnectorException +from src.core.logging import get_logger +from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus +from src.domain.interfaces import IConnectorConfigRepository, IConnectorRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("connector_service") + + +class ConnectorService: + def __init__(self, connector_repo: IConnectorRepository, config_repo: IConnectorConfigRepository) -> None: + self._connector_repo = connector_repo + self._config_repo = config_repo + + async def create_connector(self, entity: ConnectorEntity, configs: list[ConnectorConfigEntity] | None = None) -> ConnectorEntity: + created = await self._connector_repo.create(entity) + + if configs: + for cfg in configs: + cfg.connector_id = created.id + await self._config_repo.upsert(cfg) + + await self._publish_event("connector.created", created.id, {"connector_type": created.connector_type, "pharmacy_id": str(created.pharmacy_id), "erp_system_id": str(created.erp_system_id), "status": created.status}) + logger.info("connector_created", extra={"connector_id": str(created.id), "connector_type": created.connector_type}) + return created + + async def get_connector(self, connector_id: UUID) -> ConnectorEntity | None: + return await self._connector_repo.get_by_id(connector_id) + + async def list_connectors(self, *, erp_system_id: UUID | None = None, pharmacy_id: UUID | None = None, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ConnectorEntity], int]: + return await self._connector_repo.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit) + + async def update_connector(self, entity: ConnectorEntity) -> ConnectorEntity: + updated = await self._connector_repo.update(entity) + await self._publish_event("connector.updated", updated.id, {"connector_type": updated.connector_type, "status": updated.status}) + return updated + + async def delete_connector(self, connector_id: UUID) -> bool: + connector = await self._connector_repo.get_by_id(connector_id) + deleted = await self._connector_repo.delete(connector_id) + if deleted: + connector_registry.remove_instance(connector_id) + if connector: + await self._publish_event("connector.deleted", connector_id, {"connector_type": connector.connector_type, "pharmacy_id": str(connector.pharmacy_id)}) + return deleted + + async def test_connection(self, connector_id: UUID) -> ConnectorResult: + connector_entity = await self._connector_repo.get_by_id(connector_id) + if not connector_entity: + raise ConnectorException("unknown", f"Connector '{connector_id}' not found") + + configs = await self._config_repo.list_by_connector(connector_id) + config_dict = {c.key: c.value for c in configs} + secrets_dict = {c.key: c.value for c in configs if c.encrypted} + + context = ConnectorContext( + connector_id=connector_entity.id, + pharmacy_id=connector_entity.pharmacy_id, + erp_system_id=connector_entity.erp_system_id, + connector_type=connector_entity.connector_type, + config=config_dict, + secrets=secrets_dict, + ) + + instance = connector_registry.get_or_create_instance(connector_id, connector_entity.connector_type, config_dict) + if hasattr(instance, "set_context"): + instance.set_context(context) + + result = await instance.heartbeat(connector_entity.pharmacy_id) + + if result.success: + connector_entity.status = ConnectorStatus.ACTIVE + connector_entity.last_heartbeat_at = datetime.now(timezone.utc) + connector_entity.last_error = None + await self._connector_repo.update(connector_entity) + await self._publish_event("connector.heartbeat", connector_id, {"status": "healthy"}) + else: + connector_entity.status = ConnectorStatus.ERROR + connector_entity.last_error = result.error + await self._connector_repo.update(connector_entity) + await self._publish_event("connector.error", connector_id, {"error": result.error}) + + return result + + async def deploy_connector(self, connector_id: UUID) -> ConnectorEntity: + entity = await self._connector_repo.get_by_id(connector_id) + if not entity: + raise ConnectorException("unknown", f"Connector '{connector_id}' not found") + + if entity.status == ConnectorStatus.ACTIVE: + raise ConnectorException(entity.connector_type, "Connector is already active. Deactivate first to redeploy.") + + configs = await self._config_repo.list_by_connector(connector_id) + config_dict = {c.key: c.value for c in configs} + + connector_registry.remove_instance(connector_id) + connector_registry.get_or_create_instance(connector_id, entity.connector_type, config_dict) + + entity.status = ConnectorStatus.ACTIVE + entity.last_heartbeat_at = datetime.now(timezone.utc) + entity.last_error = None + updated = await self._connector_repo.update(entity) + + await self._publish_event("connector.deployed", connector_id, {"connector_type": entity.connector_type, "status": "active"}) + logger.info("connector_deployed", extra={"connector_id": str(connector_id)}) + return updated + + async def deactivate_connector(self, connector_id: UUID) -> ConnectorEntity: + entity = await self._connector_repo.get_by_id(connector_id) + if not entity: + raise ConnectorException("unknown", f"Connector '{connector_id}' not found") + + connector_registry.remove_instance(connector_id) + + entity.status = ConnectorStatus.INACTIVE + updated = await self._connector_repo.update(entity) + + await self._publish_event("connector.deactivated", connector_id, {"connector_type": entity.connector_type, "status": "inactive"}) + return updated + + async def get_configs(self, connector_id: UUID) -> list[ConnectorConfigEntity]: + return await self._config_repo.list_by_connector(connector_id) + + async def upsert_config(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: + return await self._config_repo.upsert(entity) + + async def delete_config(self, config_id: UUID) -> bool: + return await self._config_repo.delete(config_id) + + async def _publish_event(self, event_type: str, connector_id: UUID, payload: dict[str, Any]) -> None: + try: + routing_key = f"connector.{event_type.split('.', 1)[1]}" if "." in event_type else f"connector.{event_type}" + full_payload = {"event_type": event_type, "connector_id": str(connector_id), "timestamp": datetime.now(timezone.utc).isoformat(), **payload} + await rabbitmq_client.publish(routing_key, full_payload) + except Exception as exc: + logger.warning("connector_event_publish_failed", extra={"event_type": event_type, "error": str(exc)}) diff --git a/pip-platform/src/services/inventory/inventory_service.py b/pip-platform/src/services/inventory/inventory_service.py new file mode 100644 index 0000000..397d457 --- /dev/null +++ b/pip-platform/src/services/inventory/inventory_service.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.core.exceptions import ConflictException, NotFoundException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import ( + InventoryHistoryEntity, + InventoryRecordEntity, + ReservationEntity, + ReservationStatus, +) +from src.domain.interfaces import IInventoryRepository, IReservationRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("inventory_service") + +RESERVATION_DEFAULT_TTL_MINUTES = 30 + + +class InventoryService: + def __init__( + self, + inventory_repo: IInventoryRepository, + reservation_repo: IReservationRepository, + ) -> None: + self._inventory_repo = inventory_repo + self._reservation_repo = reservation_repo + + async def get_inventory_record(self, pharmacy_id: UUID, medication_id: UUID) -> InventoryRecordEntity | None: + return await self._inventory_repo.get_record(pharmacy_id, medication_id) + + async def list_inventory(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[InventoryRecordEntity], int]: + return await self._inventory_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit) + + async def upsert_inventory_record(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: + existing = await self._inventory_repo.get_record(entity.pharmacy_id, entity.medication_id) + previous_stock = existing.stock if existing else 0 + + upserted = await self._inventory_repo.upsert(entity) + + delta = entity.stock - previous_stock + if delta != 0: + history = InventoryHistoryEntity( + inventory_record_id=upserted.id, + pharmacy_id=entity.pharmacy_id, + medication_id=entity.medication_id, + previous_stock=previous_stock, + new_stock=entity.stock, + delta=delta, + reason=entity.source or "upsert", + source=entity.source, + ) + await self._inventory_repo.add_history(history) + + await self._publish_event("inventory.updated", upserted.id, { + "pharmacy_id": str(entity.pharmacy_id), + "medication_id": str(entity.medication_id), + "previous_stock": previous_stock, + "new_stock": entity.stock, + "delta": delta, + }) + return upserted + + async def get_inventory_history(self, pharmacy_id: UUID, medication_id: UUID, *, offset: int = 0, limit: int = 50) -> list[InventoryHistoryEntity]: + records = await self._inventory_repo.get_history(pharmacy_id, medication_id, offset=offset, limit=limit) + return list(records) + + async def create_reservation( + self, + pharmacy_id: UUID, + medication_id: UUID, + quantity: int, + *, + user_id: UUID | None = None, + external_reference: str | None = None, + idempotency_key: str | None = None, + ttl_minutes: int = RESERVATION_DEFAULT_TTL_MINUTES, + ) -> ReservationEntity: + if idempotency_key: + existing = await self._reservation_repo.get_by_idempotency_key(idempotency_key) + if existing: + return existing + + record = await self._inventory_repo.get_record(pharmacy_id, medication_id) + if not record: + raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}") + + if record.available_stock < quantity: + raise ValidationException( + f"Insufficient available stock. Requested: {quantity}, Available: {record.available_stock}", + details={"available_stock": record.available_stock, "requested": quantity}, + ) + + record.reserved_stock += quantity + record.available_stock = record.stock - record.reserved_stock + await self._inventory_repo.upsert(record) + + now = datetime.now(timezone.utc) + entity = ReservationEntity( + pharmacy_id=pharmacy_id, + medication_id=medication_id, + user_id=user_id, + external_reference=external_reference, + quantity=quantity, + status=ReservationStatus.PENDING, + expires_at=now + __import__("datetime").timedelta(minutes=ttl_minutes), + idempotency_key=idempotency_key, + ) + created = await self._reservation_repo.create(entity) + + await self._publish_event("reservation.created", created.id, { + "pharmacy_id": str(pharmacy_id), + "medication_id": str(medication_id), + "quantity": quantity, + }) + return created + + async def confirm_reservation(self, reservation_id: UUID) -> ReservationEntity: + entity = await self._reservation_repo.get_by_id(reservation_id) + if not entity: + raise NotFoundException("Reservation", str(reservation_id)) + + if entity.status != ReservationStatus.PENDING: + raise ValidationException(f"Reservation cannot be confirmed. Current status: {entity.status}") + + now = datetime.now(timezone.utc) + if entity.expires_at and entity.expires_at < now: + entity.status = ReservationStatus.EXPIRED + await self._reservation_repo.update(entity) + await self._release_reserved_stock(entity) + await self._publish_event("reservation.expired", entity.id, {"pharmacy_id": str(entity.pharmacy_id)}) + raise ValidationException("Reservation has expired") + + entity.status = ReservationStatus.CONFIRMED + entity.confirmed_at = now + updated = await self._reservation_repo.update(entity) + + await self._publish_event("reservation.confirmed", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "medication_id": str(entity.medication_id), + "quantity": entity.quantity, + }) + return updated + + async def cancel_reservation(self, reservation_id: UUID) -> ReservationEntity: + entity = await self._reservation_repo.get_by_id(reservation_id) + if not entity: + raise NotFoundException("Reservation", str(reservation_id)) + + if entity.status not in (ReservationStatus.PENDING, ReservationStatus.CONFIRMED): + raise ValidationException(f"Reservation cannot be cancelled. Current status: {entity.status}") + + entity.status = ReservationStatus.CANCELLED + entity.cancelled_at = datetime.now(timezone.utc) + updated = await self._reservation_repo.update(entity) + + await self._release_reserved_stock(entity) + + await self._publish_event("reservation.cancelled", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "medication_id": str(entity.medication_id), + "quantity": entity.quantity, + }) + return updated + + async def get_reservation(self, reservation_id: UUID) -> ReservationEntity | None: + return await self._reservation_repo.get_by_id(reservation_id) + + async def list_reservations(self, pharmacy_id: UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ReservationEntity], int]: + return await self._reservation_repo.list_by_pharmacy(pharmacy_id, status=status, offset=offset, limit=limit) + + async def expire_pending_reservations(self) -> int: + pass + + async def _release_reserved_stock(self, reservation: ReservationEntity) -> None: + record = await self._inventory_repo.get_record(reservation.pharmacy_id, reservation.medication_id) + if not record: + return + + record.reserved_stock = max(0, record.reserved_stock - reservation.quantity) + record.available_stock = record.stock - record.reserved_stock + updated = await self._inventory_repo.upsert(record) + + history = InventoryHistoryEntity( + inventory_record_id=updated.id, + pharmacy_id=reservation.pharmacy_id, + medication_id=reservation.medication_id, + previous_stock=record.stock + reservation.quantity if reservation.status == ReservationStatus.PENDING else record.stock, + new_stock=record.stock, + delta=-reservation.quantity, + reason=f"reservation_{reservation.status}", + source="inventory_service", + ) + await self._inventory_repo.add_history(history) + + async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None: + try: + routing_key = event_type.replace(".", "_") + full_payload = { + "event_type": event_type, + "resource_id": str(resource_id), + "timestamp": datetime.now(timezone.utc).isoformat(), + **payload, + } + await rabbitmq_client.publish(routing_key, full_payload) + except Exception as exc: + logger.warning("inventory_event_publish_failed", extra={"event_type": event_type, "error": str(exc)}) diff --git a/pip-platform/src/services/medication/__init__.py b/pip-platform/src/services/medication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/medication/medication_service.py b/pip-platform/src/services/medication/medication_service.py new file mode 100644 index 0000000..12bb413 --- /dev/null +++ b/pip-platform/src/services/medication/medication_service.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.core.exceptions import ConflictException, NotFoundException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import MedicationCatalogEntryEntity, MedicationEntity +from src.domain.interfaces import IMedicationCatalogRepository, IMedicationRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("medication_service") + + +class MedicationService: + def __init__( + self, + medication_repo: IMedicationRepository, + catalog_repo: IMedicationCatalogRepository, + ) -> None: + self._medication_repo = medication_repo + self._catalog_repo = catalog_repo + + async def create_medication(self, entity: MedicationEntity) -> MedicationEntity: + existing = await self._medication_repo.get_by_nregistro(entity.nregistro) + if existing: + raise ConflictException(f"Medication with nregistro '{entity.nregistro}' already exists") + created = await self._medication_repo.create(entity) + await self._publish_event("medication.created", created.id, {"nregistro": created.nregistro, "name": created.name}) + return created + + async def get_medication(self, medication_id: UUID) -> MedicationEntity | None: + return await self._medication_repo.get_by_id(medication_id) + + async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: + return await self._medication_repo.get_by_nregistro(nregistro) + + async def search_medications(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[list[MedicationEntity], int]: + return await self._medication_repo.search(query, offset=offset, limit=limit) + + async def update_medication(self, entity: MedicationEntity) -> MedicationEntity: + existing = await self._medication_repo.get_by_id(entity.id) # type: ignore[arg-type] + if not existing: + raise NotFoundException("Medication", str(entity.id)) + updated = await self._medication_repo.update(entity) + await self._publish_event("medication.updated", updated.id, {"nregistro": updated.nregistro}) + return updated + + async def delete_medication(self, medication_id: UUID) -> bool: + deleted = await self._medication_repo.delete(medication_id) + if deleted: + await self._publish_event("medication.deleted", medication_id, {}) + return deleted + + async def get_catalog_entry(self, pharmacy_id: UUID, medication_id: UUID) -> MedicationCatalogEntryEntity | None: + return await self._catalog_repo.get_entry(pharmacy_id, medication_id) + + async def list_catalog(self, pharmacy_id: UUID, *, offset: int = 0, limit: int = 50) -> tuple[list[MedicationCatalogEntryEntity], int]: + return await self._catalog_repo.list_by_pharmacy(pharmacy_id, offset=offset, limit=limit) + + async def upsert_catalog_entry(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: + upserted = await self._catalog_repo.upsert(entity) + await self._publish_event("catalog.upserted", upserted.id, { + "pharmacy_id": str(entity.pharmacy_id), + "medication_id": str(entity.medication_id), + "is_available": entity.is_available, + "stock": entity.stock, + }) + return upserted + + async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None: + try: + routing_key = event_type.replace(".", "_") + full_payload = { + "event_type": event_type, + "resource_id": str(resource_id), + "timestamp": datetime.now(timezone.utc).isoformat(), + **payload, + } + await rabbitmq_client.publish(routing_key, full_payload) + except Exception as exc: + logger.warning("medication_event_publish_failed", extra={"event_type": event_type, "error": str(exc)}) diff --git a/pip-platform/src/services/notification/__init__.py b/pip-platform/src/services/notification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/pharmacy/__init__.py b/pip-platform/src/services/pharmacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/src/services/synchronization/__init__.py b/pip-platform/src/services/synchronization/__init__.py new file mode 100644 index 0000000..d78d0fe --- /dev/null +++ b/pip-platform/src/services/synchronization/__init__.py @@ -0,0 +1,3 @@ +from .sync_service import SyncService + +__all__ = ["SyncService"] diff --git a/pip-platform/src/services/synchronization/sync_service.py b/pip-platform/src/services/synchronization/sync_service.py new file mode 100644 index 0000000..57d3c63 --- /dev/null +++ b/pip-platform/src/services/synchronization/sync_service.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from src.core.exceptions import NotFoundException, ValidationException +from src.core.logging import get_logger +from src.domain.entities import SyncJobEntity, SyncJobStatus, SyncStrategy +from src.domain.interfaces import ISyncJobRepository, ISyncLogRepository +from src.infrastructure.messaging.rabbitmq import rabbitmq_client + +logger = get_logger("sync_service") + + +class SyncService: + def __init__( + self, + sync_job_repo: ISyncJobRepository, + sync_log_repo: ISyncLogRepository, + ) -> None: + self._sync_job_repo = sync_job_repo + self._sync_log_repo = sync_log_repo + + async def create_sync_job( + self, + pharmacy_id: UUID, + connector_id: UUID, + strategy: SyncStrategy, + ) -> SyncJobEntity: + entity = SyncJobEntity( + pharmacy_id=pharmacy_id, + connector_id=connector_id, + strategy=strategy, + status=SyncJobStatus.PENDING, + ) + created = await self._sync_job_repo.create(entity) + await self._publish_event("sync.created", created.id, { + "pharmacy_id": str(pharmacy_id), + "connector_id": str(connector_id), + "strategy": strategy, + }) + return created + + async def get_sync_job(self, job_id: UUID) -> SyncJobEntity | None: + return await self._sync_job_repo.get_by_id(job_id) + + async def list_sync_jobs( + self, + pharmacy_id: UUID, + *, + status: str | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[SyncJobEntity], int]: + return await self._sync_job_repo.list_by_pharmacy( + pharmacy_id, status=status, offset=offset, limit=limit, + ) + + async def trigger_sync(self, job_id: UUID) -> SyncJobEntity: + entity = await self._sync_job_repo.get_by_id(job_id) + if not entity: + raise NotFoundException("SyncJob", str(job_id)) + + if entity.status not in (SyncJobStatus.PENDING, SyncJobStatus.FAILED): + raise ValidationException( + f"Cannot trigger sync job. Current status: {entity.status}", + details={"current_status": entity.status}, + ) + + entity.status = SyncJobStatus.RUNNING + entity.started_at = datetime.now(timezone.utc) + entity.error_message = None + updated = await self._sync_job_repo.update(entity) + + await self._add_log(job_id, "INFO", "Sync job triggered") + await self._publish_event("sync.started", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "strategy": entity.strategy, + }) + return updated + + async def cancel_sync(self, job_id: UUID) -> SyncJobEntity: + entity = await self._sync_job_repo.get_by_id(job_id) + if not entity: + raise NotFoundException("SyncJob", str(job_id)) + + if entity.status != SyncJobStatus.RUNNING: + raise ValidationException( + f"Cannot cancel sync job. Current status: {entity.status}", + details={"current_status": entity.status}, + ) + + entity.status = SyncJobStatus.CANCELLED + entity.completed_at = datetime.now(timezone.utc) + updated = await self._sync_job_repo.update(entity) + + await self._add_log(job_id, "WARNING", "Sync job cancelled by user") + await self._publish_event("sync.cancelled", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + }) + return updated + + async def complete_sync( + self, + job_id: UUID, + *, + items_total: int | None = None, + items_processed: int | None = None, + items_failed: int | None = None, + ) -> SyncJobEntity: + entity = await self._sync_job_repo.get_by_id(job_id) + if not entity: + raise NotFoundException("SyncJob", str(job_id)) + + entity.status = SyncJobStatus.COMPLETED + entity.completed_at = datetime.now(timezone.utc) + if items_total is not None: + entity.items_total = items_total + if items_processed is not None: + entity.items_processed = items_processed + if items_failed is not None: + entity.items_failed = items_failed + updated = await self._sync_job_repo.update(entity) + + await self._add_log(job_id, "INFO", f"Sync completed. Processed: {items_processed}, Failed: {items_failed}") + await self._publish_event("sync.completed", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "items_total": items_total, + "items_processed": items_processed, + "items_failed": items_failed, + }) + return updated + + async def fail_sync(self, job_id: UUID, error_message: str) -> SyncJobEntity: + entity = await self._sync_job_repo.get_by_id(job_id) + if not entity: + raise NotFoundException("SyncJob", str(job_id)) + + entity.status = SyncJobStatus.FAILED + entity.completed_at = datetime.now(timezone.utc) + entity.error_message = error_message + updated = await self._sync_job_repo.update(entity) + + await self._add_log(job_id, "ERROR", f"Sync failed: {error_message}") + await self._publish_event("sync.failed", updated.id, { + "pharmacy_id": str(entity.pharmacy_id), + "error_message": error_message, + }) + return updated + + async def get_sync_logs( + self, + job_id: UUID, + *, + level: str | None = None, + offset: int = 0, + limit: int = 100, + ): + return await self._sync_log_repo.list_by_job(job_id, level=level, offset=offset, limit=limit) + + async def _add_log(self, job_id: UUID, level: str, message: str, details: dict | None = None) -> None: + from src.domain.entities import SyncLogEntity + log = SyncLogEntity(sync_job_id=job_id, level=level, message=message, details=details) + await self._sync_log_repo.add(log) + + async def _publish_event(self, event_type: str, resource_id: UUID, payload: dict[str, Any]) -> None: + try: + routing_key = event_type.replace(".", "_") + full_payload = { + "event_type": event_type, + "resource_id": str(resource_id), + "timestamp": datetime.now(timezone.utc).isoformat(), + **payload, + } + await rabbitmq_client.publish(routing_key, full_payload) + except Exception as exc: + logger.warning("sync_event_publish_failed", extra={"event_type": event_type, "error": str(exc)}) diff --git a/pip-platform/tests/__init__.py b/pip-platform/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/tests/conftest.py b/pip-platform/tests/conftest.py new file mode 100644 index 0000000..128fbb7 --- /dev/null +++ b/pip-platform/tests/conftest.py @@ -0,0 +1,78 @@ +import asyncio +from collections.abc import AsyncGenerator +from uuid import uuid4 + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from src.infrastructure.database.models import Base +from src.main import app + + +TEST_DATABASE_URL = "postgresql+asyncpg://pip:pip-secret@localhost:5432/pip_test" + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="session") +async def test_engine(): + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: + session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with session_factory() as session: + yield session + await session.rollback() + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest.fixture +def sample_pharmacy_data(): + return { + "code": f"PHARM-{uuid4().hex[:8]}", + "name": "Test Pharmacy", + "address": "Calle Test 123", + "city": "Madrid", + "province": "Madrid", + "postal_code": "28001", + "country": "ES", + "latitude": 40.4168, + "longitude": -3.7038, + "phone": "+34912345678", + "email": "test@pharmacy.es", + "erp_type": "farmatic", + "sync_strategy": "incremental", + "sync_interval_minutes": 30, + } + + +@pytest.fixture +def sample_user_data(): + return { + "username": f"testuser_{uuid4().hex[:8]}", + "password": "TestPass123!", + "email": "test@pip.es", + "full_name": "Test User", + "role": "viewer", + } diff --git a/pip-platform/tests/integration/__init__.py b/pip-platform/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/tests/integration/test_api_key_auth.py b/pip-platform/tests/integration/test_api_key_auth.py new file mode 100644 index 0000000..c64dc27 --- /dev/null +++ b/pip-platform/tests/integration/test_api_key_auth.py @@ -0,0 +1,144 @@ +"""E2E tests for API Key authentication flow. + +Tests dual auth support (JWT Bearer + X-API-Key header), +API Key CRUD, scope enforcement, and role-based access control. +""" + +import asyncio +import os + +import httpx + +BASE_URL = os.environ.get("PIP_BASE_URL", "http://localhost:8000") +ADMIN_CREDS = {"username": os.environ.get("PIP_ADMIN_USER", "synctest"), "password": os.environ.get("PIP_ADMIN_PASS", "Test123!")} + + +async def get_admin_token(client: httpx.AsyncClient) -> str: + r = await client.post("/api/v1/auth/token", json=ADMIN_CREDS) + assert r.status_code == 200, f"Login failed: {r.status_code} {r.text[:200]}" + return r.json()["access_token"] + + +def bearer_headers(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +async def test_api_key_crud(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + clients = await c.get("/api/v1/api-clients", headers=h) + assert clients.status_code == 200 + client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None) + + if not client_id: + resp = await c.post("/api/v1/api-clients", headers=h, json={ + "name": "Test Client", + "description": "E2E test client", + "scopes": ["pharmacies:read", "medications:read", "inventory:read"], + }) + assert resp.status_code == 201 + client_id = resp.json()["id"] + + key_resp = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "E2E CRUD Key"}) + assert key_resp.status_code == 201, f"Key create failed: {key_resp.status_code} {key_resp.text[:200]}" + raw_key = key_resp.json()["key"] + key_prefix = key_resp.json()["key_prefix"] + assert len(raw_key) > 20 + assert len(key_prefix) == 8 + + keys = await c.get(f"/api/v1/api-clients/{client_id}/keys", headers=h) + assert keys.status_code == 200 + assert len(keys.json()) >= 1 + + print(" PASS: test_api_key_crud") + + +async def test_api_key_read_access(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + clients = await c.get("/api/v1/api-clients", headers=h) + client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None) + kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Read Access Key"}) + raw_key = kr.json()["key"] + api_h = {"X-API-Key": raw_key} + + r = await c.get("/api/v1/pharmacies", headers=api_h) + assert r.status_code == 200 + assert r.json()["total"] >= 0 + + r2 = await c.get("/api/v1/medications", headers=api_h) + assert r2.status_code == 200 + + r3 = await c.get("/api/v1/inventory", headers=api_h) + assert r3.status_code == 200 + + print(" PASS: test_api_key_read_access") + + +async def test_api_key_write_blocked(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + clients = await c.get("/api/v1/api-clients", headers=h) + client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None) + kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Write Block Key"}) + raw_key = kr.json()["key"] + api_h = {"X-API-Key": raw_key} + + r = await c.post("/api/v1/pharmacies", headers=api_h, json={"code": "BLOCKED", "name": "Should Fail"}) + assert r.status_code == 403, f"Expected 403, got {r.status_code}" + + print(" PASS: test_api_key_write_blocked") + + +async def test_invalid_api_key(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + r = await c.get("/api/v1/pharmacies", headers={"X-API-Key": "invalidkey12345"}) + assert r.status_code == 401 + + print(" PASS: test_invalid_api_key") + + +async def test_no_auth_rejected(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + r = await c.get("/api/v1/pharmacies") + assert r.status_code == 401 + + print(" PASS: test_no_auth_rejected") + + +async def test_dual_auth_bearer_priority(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + clients = await c.get("/api/v1/api-clients", headers=h) + client_id = next((ci["id"] for ci in clients.json().get("items", []) if ci["name"] == "Test Client"), None) + kr = await c.post(f"/api/v1/api-clients/{client_id}/keys", headers=h, json={"name": "Dual Auth Key"}) + raw_key = kr.json()["key"] + + dual_h = {"Authorization": f"Bearer {token}", "X-API-Key": raw_key} + r = await c.get("/api/v1/pharmacies", headers=dual_h) + assert r.status_code == 200 + + print(" PASS: test_dual_auth_bearer_priority") + + +async def run_all(): + print("=== API Key Auth E2E Tests ===") + await test_api_key_crud() + await test_api_key_read_access() + await test_api_key_write_blocked() + await test_invalid_api_key() + await test_no_auth_rejected() + await test_dual_auth_bearer_priority() + print("=== All API Key tests passed ===") + + +if __name__ == "__main__": + asyncio.run(run_all()) diff --git a/pip-platform/tests/integration/test_rate_limiting.py b/pip-platform/tests/integration/test_rate_limiting.py new file mode 100644 index 0000000..885a717 --- /dev/null +++ b/pip-platform/tests/integration/test_rate_limiting.py @@ -0,0 +1,105 @@ +"""E2E tests for Rate Limiting middleware. + +Tests that rate limiting returns 429 with proper headers +when request count exceeds the configured limit. +""" + +import asyncio +import os + +import httpx + +BASE_URL = os.environ.get("PIP_BASE_URL", "http://localhost:8000") +ADMIN_CREDS = {"username": os.environ.get("PIP_ADMIN_USER", "synctest"), "password": os.environ.get("PIP_ADMIN_PASS", "Test123!")} + + +async def get_admin_token(client: httpx.AsyncClient) -> str: + r = await client.post("/api/v1/auth/token", json=ADMIN_CREDS) + assert r.status_code == 200 + return r.json()["access_token"] + + +def bearer_headers(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +async def test_rate_limit_headers(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + r = await c.get("/api/v1/pharmacies", headers=h) + assert r.status_code == 200 + assert "x-ratelimit-limit" in r.headers, f"Missing X-RateLimit-Limit header. Got: {list(r.headers.keys())}" + assert "x-ratelimit-remaining" in r.headers + assert "x-ratelimit-reset" in r.headers + + limit = int(r.headers["x-ratelimit-limit"]) + assert limit > 0 + + print(f" PASS: test_rate_limit_headers (limit={limit}/min)") + + +async def test_rate_limit_429(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + r0 = await c.get("/api/v1/pharmacies", headers=h) + limit = int(r0.headers["x-ratelimit-limit"]) + + successes = 0 + blocked = 0 + for i in range(limit + 10): + r = await c.get("/api/v1/pharmacies", headers=h) + if r.status_code == 200: + successes += 1 + elif r.status_code == 429: + blocked += 1 + assert "retry-after" in r.headers, "429 response missing Retry-After header" + break + + assert blocked > 0, f"Rate limit was not triggered after {limit + 10} requests" + + print(f" PASS: test_rate_limit_429 (blocked after {successes} requests)") + + +async def test_rate_limit_exempt_paths(): + async with httpx.AsyncClient(base_url=BASE_URL) as c: + r = await c.get("/docs") + assert r.status_code == 200 + assert "x-ratelimit-limit" not in r.headers, "Docs should be exempt from rate limiting" + + print(" PASS: test_rate_limit_exempt_paths") + + +async def test_rate_limit_per_client(): + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as c: + token = await get_admin_token(c) + h = bearer_headers(token) + + r0 = await c.get("/api/v1/pharmacies", headers=h) + limit = int(r0.headers["x-ratelimit-limit"]) + + for _ in range(limit + 5): + r = await c.get("/api/v1/pharmacies", headers=h) + if r.status_code == 429: + break + + r2 = await c.get("/api/v1/system/health") + assert r2.status_code == 200, "Health endpoint should work regardless of rate limit on other paths" + + print(" PASS: test_rate_limit_per_client") + + +async def run_all(): + print("=== Rate Limiting E2E Tests ===") + await test_rate_limit_headers() + await test_rate_limit_429() + await test_rate_limit_exempt_paths() + await test_rate_limit_per_client() + print("=== All Rate Limiting tests passed ===") + + +if __name__ == "__main__": + asyncio.run(run_all()) diff --git a/pip-platform/tests/unit/__init__.py b/pip-platform/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pip-platform/tests/unit/test_auth.py b/pip-platform/tests/unit/test_auth.py new file mode 100644 index 0000000..37acd22 --- /dev/null +++ b/pip-platform/tests/unit/test_auth.py @@ -0,0 +1,94 @@ +import pytest +from httpx import AsyncClient + +from src.services.auth.security import create_access_token, hash_password, verify_password +from src.domain.entities import UserRole + + +class TestPasswordHashing: + def test_hash_and_verify(self): + password = "SecurePass123!" + hashed = hash_password(password) + assert hashed != password + assert verify_password(password, hashed) + + def test_verify_wrong_password(self): + hashed = hash_password("CorrectPass123!") + assert not verify_password("WrongPass123!", hashed) + + +class TestJWT: + def test_create_access_token(self): + token = create_access_token( + subject="test-user-id", + role=UserRole.ADMIN, + scopes=["*"], + ) + assert isinstance(token, str) + assert len(token) > 0 + + def test_decode_access_token(self): + from src.services.auth.security import decode_token, validate_token_type, TokenData + + token = create_access_token( + subject="test-user-id", + role=UserRole.VIEWER, + scopes=["pharmacies:read"], + ) + payload = decode_token(token) + assert payload["sub"] == "test-user-id" + assert payload["role"] == "viewer" + assert payload["type"] == "access" + + validated = validate_token_type(payload, "access") + assert validated is not None + + def test_invalid_token_raises(self): + from src.core.exceptions import UnauthorizedException + from src.services.auth.security import decode_token + + with pytest.raises(UnauthorizedException): + decode_token("invalid.token.here") + + +class TestRBAC: + def test_superadmin_can_access_all(self): + from src.services.auth.security import RBACGuard, TokenData + + token_data = TokenData({"sub": "1", "role": "superadmin", "scopes": ["*"], "type": "access", "iss": "pip-platform", "jti": "test"}) + assert RBACGuard.has_role(token_data, UserRole.ADMIN) + assert RBACGuard.has_role(token_data, UserRole.VIEWER) + assert RBACGuard.has_role(token_data, UserRole.OPERATOR) + + def test_viewer_cannot_access_admin(self): + from src.services.auth.security import RBACGuard, TokenData + + token_data = TokenData({"sub": "2", "role": "viewer", "scopes": [], "type": "access", "iss": "pip-platform", "jti": "test"}) + assert not RBACGuard.has_role(token_data, UserRole.ADMIN) + + def test_wildcard_scope_grants_all(self): + from src.services.auth.security import RBACGuard, TokenData + + token_data = TokenData({"sub": "1", "role": "superadmin", "scopes": ["*"], "type": "access", "iss": "pip-platform", "jti": "test"}) + assert RBACGuard.has_scope(token_data, "pharmacies:read") + assert RBACGuard.has_scope(token_data, "pharmacies:write") + assert RBACGuard.has_scope(token_data, "anything:anything") + + def test_specific_scope(self): + from src.services.auth.security import RBACGuard, TokenData + + token_data = TokenData({"sub": "2", "role": "viewer", "scopes": ["pharmacies:read"], "type": "access", "iss": "pip-platform", "jti": "test"}) + assert RBACGuard.has_scope(token_data, "pharmacies:read") + assert not RBACGuard.has_scope(token_data, "pharmacies:write") + + +class TestAPIKey: + def test_generate_api_key(self): + from src.services.auth.security import generate_api_key, verify_api_key + + raw_key, prefix, key_hash = generate_api_key() + assert len(raw_key) > 20 + assert len(prefix) == 8 + assert raw_key[:8] == prefix + assert verify_api_key(raw_key, key_hash) + assert not verify_api_key("wrong-key", key_hash) diff --git a/pip-platform/tests/unit/test_connector_registry.py b/pip-platform/tests/unit/test_connector_registry.py new file mode 100644 index 0000000..80aedb4 --- /dev/null +++ b/pip-platform/tests/unit/test_connector_registry.py @@ -0,0 +1,102 @@ +from uuid import uuid4 + +import pytest + +from src.connectors.registry import ConnectorRegistry, _BUILTIN_CONNECTORS +from src.connectors.mock.mock_connector import MockConnector +from src.connectors.base import BaseInventoryConnector +from src.core.exceptions import ConnectorException + + +@pytest.fixture +def registry() -> ConnectorRegistry: + ConnectorRegistry._instance = None + reg = ConnectorRegistry.get_registry() + return reg + + +class TestConnectorRegistrySingleton: + def test_singleton(self, registry: ConnectorRegistry): + reg2 = ConnectorRegistry.get_registry() + assert reg2 is registry + + def test_new_instance_after_reset(self, registry: ConnectorRegistry): + ConnectorRegistry._instance = None + reg2 = ConnectorRegistry.get_registry() + assert reg2 is not registry + + +class TestConnectorRegistryBuiltinTypes: + def test_builtin_connectors_include_mock(self): + assert "mock" in _BUILTIN_CONNECTORS + + def test_builtin_connectors_include_farmatic(self): + assert "farmatic" in _BUILTIN_CONNECTORS + + def test_builtin_connectors_include_custom_rest(self): + assert "custom_rest" in _BUILTIN_CONNECTORS + + +class TestConnectorRegistryGetClass: + def test_get_mock_connector_class(self, registry: ConnectorRegistry): + cls = registry.get_connector_class("mock") + assert cls is MockConnector + + def test_get_unknown_type_raises(self, registry: ConnectorRegistry): + with pytest.raises(ConnectorException, match="No connector registered"): + registry.get_connector_class("nonexistent_type") + + +class TestConnectorRegistryCreateInstance: + def test_create_mock_instance(self, registry: ConnectorRegistry): + instance = registry.create_instance("mock") + assert isinstance(instance, BaseInventoryConnector) + assert instance.connector_type == "mock" + + def test_create_instance_with_config(self, registry: ConnectorRegistry): + instance = registry.create_instance("mock", {"simulate_latency": False, "error_rate": 0.0}) + assert instance._simulate_latency is False + + def test_create_unknown_type_raises(self, registry: ConnectorRegistry): + with pytest.raises(ConnectorException): + registry.create_instance("nonexistent") + + +class TestConnectorRegistryInstanceCache: + def test_get_or_create_returns_cached(self, registry: ConnectorRegistry): + cid = uuid4() + inst1 = registry.get_or_create_instance(cid, "mock") + inst2 = registry.get_or_create_instance(cid, "mock") + assert inst1 is inst2 + + def test_remove_instance(self, registry: ConnectorRegistry): + cid = uuid4() + registry.get_or_create_instance(cid, "mock") + registry.remove_instance(cid) + assert cid not in registry._instances + + def test_remove_nonexistent_instance_no_error(self, registry: ConnectorRegistry): + registry.remove_instance(uuid4()) + + +class TestConnectorRegistryListTypes: + def test_list_registered_types(self, registry: ConnectorRegistry): + types = registry.list_registered_types() + assert "mock" in types + assert "farmatic" in types + assert "custom_rest" in types + + def test_register_custom_type(self, registry: ConnectorRegistry): + mock = MockConnector() + mock.connector_type = "test_custom" + registry.register("test_custom", mock) + types = registry.list_registered_types() + assert "test_custom" in types + + def test_unregister_type(self, registry: ConnectorRegistry): + mock = MockConnector() + mock.connector_type = "temp_type" + registry.register("temp_type", mock) + registry.unregister("temp_type") + types = registry.list_registered_types() + assert "temp_type" not in types diff --git a/pip-platform/tests/unit/test_connector_sdk.py b/pip-platform/tests/unit/test_connector_sdk.py new file mode 100644 index 0000000..d0e36af --- /dev/null +++ b/pip-platform/tests/unit/test_connector_sdk.py @@ -0,0 +1,258 @@ +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.connectors.sdk.circuit_breaker import CircuitBreaker, CircuitState +from src.connectors.sdk.retry import RetryPolicy, with_retry +from src.connectors.sdk.context import ConnectorContext +from src.connectors.sdk.auth import BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler +from src.core.exceptions import ServiceUnavailableException + + +class TestCircuitBreaker: + def test_initial_state_is_closed(self): + cb = CircuitBreaker(name="test") + assert cb.state == CircuitState.CLOSED + + def test_opens_after_threshold_failures(self): + cb = CircuitBreaker(name="test", failure_threshold=3) + for _ in range(3): + cb._on_failure(Exception("fail")) + assert cb._state == CircuitState.OPEN + + def test_closed_does_not_raise(self): + cb = CircuitBreaker(name="test") + + async def _run(): + coro = cb.call(AsyncMock(return_value="ok")) + return await coro + + import asyncio + result = asyncio.get_event_loop().run_until_complete(_run()) + assert result == "ok" + + def test_open_raises_service_unavailable(self): + cb = CircuitBreaker(name="test", failure_threshold=1) + cb._on_failure(Exception("fail")) + assert cb.state == CircuitState.OPEN + + async def _run(): + await cb.call(AsyncMock(return_value="ok")) + + import asyncio + with pytest.raises(ServiceUnavailableException): + asyncio.get_event_loop().run_until_complete(_run()) + + def test_half_open_after_recovery_timeout(self): + cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) + cb._on_failure(Exception("fail")) + assert cb._state == CircuitState.OPEN + time.sleep(0.02) + assert cb.state == CircuitState.HALF_OPEN + + def test_reset(self): + cb = CircuitBreaker(name="test", failure_threshold=1) + cb._on_failure(Exception("fail")) + cb.reset() + assert cb.state == CircuitState.CLOSED + assert cb._failure_count == 0 + + def test_success_resets_failure_count(self): + cb = CircuitBreaker(name="test", failure_threshold=5) + cb._on_failure(Exception("fail")) + cb._on_failure(Exception("fail")) + assert cb._failure_count == 2 + cb._on_success() + assert cb._failure_count == 0 + + def test_half_open_success_closes_circuit(self): + cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) + cb._on_failure(Exception("fail")) + time.sleep(0.02) + assert cb.state == CircuitState.HALF_OPEN + cb._on_success() + assert cb._state == CircuitState.CLOSED + + def test_half_open_failure_reopens(self): + cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) + cb._on_failure(Exception("fail")) + time.sleep(0.02) + assert cb.state == CircuitState.HALF_OPEN + cb._on_failure(Exception("fail again")) + assert cb._state == CircuitState.OPEN + + def test_get_status(self): + cb = CircuitBreaker(name="test") + status = cb.get_status() + assert status["name"] == "test" + assert status["state"] == "closed" + + +class TestRetryPolicy: + def test_default_values(self): + policy = RetryPolicy() + assert policy.max_attempts == 3 + assert policy.base_delay == 1.0 + assert policy.max_delay == 30.0 + + def test_custom_values(self): + policy = RetryPolicy(max_attempts=5, base_delay=0.5, max_delay=10.0) + assert policy.max_attempts == 5 + assert policy.base_delay == 0.5 + assert policy.max_delay == 10.0 + + +class TestWithRetry: + @pytest.mark.asyncio + async def test_success_first_try(self): + call_count = 0 + + async def _success(): + nonlocal call_count + call_count += 1 + return "ok" + + policy = RetryPolicy(max_attempts=3) + result = await with_retry(_success, policy=policy, connector_type="test", operation="test_op") + assert result == "ok" + assert call_count == 1 + + @pytest.mark.asyncio + async def test_retry_on_failure_then_success(self): + call_count = 0 + + async def _flaky(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise Exception("temp fail") + return "ok" + + policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1) + result = await with_retry(_flaky, policy=policy, connector_type="test", operation="flaky_op") + assert result == "ok" + assert call_count == 3 + + @pytest.mark.asyncio + async def test_exhaust_retries(self): + call_count = 0 + + async def _always_fail(): + nonlocal call_count + call_count += 1 + raise Exception("always fail") + + policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1) + with pytest.raises(Exception, match="always fail"): + await with_retry(_always_fail, policy=policy, connector_type="test", operation="fail_op") + assert call_count == 2 + + +class TestConnectorContext: + def test_cache_key(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="farmatic", + ) + key = ctx.cache_key("stock") + assert "farmatic" in key + assert "stock" in key + + def test_get_config(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="mock", + config={"base_url": "https://example.com"}, + ) + assert ctx.get_config("base_url") == "https://example.com" + assert ctx.get_config("missing", "default") == "default" + + def test_get_secret(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="mock", + secrets={"api_key": "secret123"}, + ) + assert ctx.get_secret("api_key") == "secret123" + assert ctx.get_secret("missing") is None + + def test_require_secret_raises(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="mock", + ) + with pytest.raises(ValueError, match="Required secret"): + ctx.require_secret("missing") + + def test_require_config_raises(self): + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="mock", + ) + with pytest.raises(ValueError, match="Required config"): + ctx.require_config("missing") + + +class TestBasicAuthHandler: + @pytest.mark.asyncio + async def test_apply_basic_auth(self): + import httpx + + handler = BasicAuthHandler() + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="test", + config={"username": "user1", "password": "pass1"}, + ) + request = httpx.Request("GET", "https://example.com") + result = await handler.apply(request, ctx) + assert "Basic" in result.headers["Authorization"] + + +class TestBearerAuthHandler: + @pytest.mark.asyncio + async def test_apply_bearer_auth(self): + import httpx + + handler = BearerAuthHandler() + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="test", + config={"access_token": "token123"}, + ) + request = httpx.Request("GET", "https://example.com") + result = await handler.apply(request, ctx) + assert result.headers["Authorization"] == "Bearer token123" + + +class TestAPIKeyAuthHandler: + @pytest.mark.asyncio + async def test_apply_api_key_auth(self): + import httpx + + handler = APIKeyAuthHandler(header_name="X-Custom-Key") + ctx = ConnectorContext( + connector_id=MagicMock(), + pharmacy_id=MagicMock(), + erp_system_id=MagicMock(), + connector_type="test", + secrets={"api_key": "key123"}, + ) + request = httpx.Request("GET", "https://example.com") + result = await handler.apply(request, ctx) + assert result.headers["X-Custom-Key"] == "key123" diff --git a/pip-platform/tests/unit/test_entities.py b/pip-platform/tests/unit/test_entities.py new file mode 100644 index 0000000..7194513 --- /dev/null +++ b/pip-platform/tests/unit/test_entities.py @@ -0,0 +1,111 @@ +import pytest + +from src.domain.entities import ( + PharmacyEntity, + PharmacyStatus, + MedicationEntity, + ERPType, + SyncStrategy, + UserRole, + ConnectorEntity, + ConnectorStatus, + ReservationEntity, + ReservationStatus, + AuditLogEntity, +) +from uuid import uuid4 + + +class TestPharmacyEntity: + def test_create_pharmacy(self): + entity = PharmacyEntity( + code="PH-001", + name="Farmacia Central", + address="Calle Mayor 1", + city="Madrid", + status=PharmacyStatus.ACTIVE, + erp_type=ERPType.FARMATIC, + sync_strategy=SyncStrategy.INCREMENTAL, + ) + assert entity.code == "PH-001" + assert entity.name == "Farmacia Central" + assert entity.status == PharmacyStatus.ACTIVE + assert entity.erp_type == ERPType.FARMATIC + assert entity.sync_strategy == SyncStrategy.INCREMENTAL + + def test_pharmacy_default_values(self): + entity = PharmacyEntity(code="PH-002", name="Test") + assert entity.country == "ES" + assert entity.status == PharmacyStatus.PENDING + assert entity.sync_strategy == SyncStrategy.INCREMENTAL + assert entity.sync_interval_minutes == 30 + + def test_pharmacy_status_enum(self): + assert PharmacyStatus.ACTIVE.value == "active" + assert PharmacyStatus.INACTIVE.value == "inactive" + assert PharmacyStatus.SUSPENDED.value == "suspended" + assert PharmacyStatus.PENDING.value == "pending" + + +class TestMedicationEntity: + def test_create_medication(self): + entity = MedicationEntity( + nregistro="12345", + name="Ibuprofeno 400mg", + active_ingredient="Ibuprofeno", + dosage="400mg", + form="Comprimidos", + prescription_required=False, + ) + assert entity.nregistro == "12345" + assert entity.name == "Ibuprofeno 400mg" + assert entity.prescription_required is False + + +class TestConnectorEntity: + def test_create_connector(self): + entity = ConnectorEntity( + erp_system_id=uuid4(), + pharmacy_id=uuid4(), + name="Farmatic Connector", + connector_type=ERPType.FARMATIC, + ) + assert entity.connector_type == ERPType.FARMATIC + assert entity.version == "1.0.0" + assert entity.status == ConnectorStatus.PENDING + + +class TestReservationEntity: + def test_create_reservation(self): + entity = ReservationEntity( + pharmacy_id=uuid4(), + medication_id=uuid4(), + quantity=5, + idempotency_key="unique-key-123", + ) + assert entity.quantity == 5 + assert entity.status == ReservationStatus.PENDING + assert entity.idempotency_key == "unique-key-123" + + +class TestAuditLogEntity: + def test_create_audit_log(self): + entity = AuditLogEntity( + actor_id=uuid4(), + actor_type="technical_user", + action="pharmacy.create", + resource_type="pharmacy", + resource_id="PH-001", + ip_address="192.168.1.1", + ) + assert entity.action == "pharmacy.create" + assert entity.resource_type == "pharmacy" + + def test_enums(self): + assert ERPType.FARMATIC.value == "farmatic" + assert ERPType.NIXFARMA.value == "nixfarma" + assert ERPType.UNYCOP.value == "unycop" + assert ERPType.MOCK.value == "mock" + assert SyncStrategy.FULL.value == "full" + assert SyncStrategy.HYBRID.value == "hybrid" + assert UserRole.SUPERADMIN.value == "superadmin" diff --git a/pip-platform/tests/unit/test_exceptions.py b/pip-platform/tests/unit/test_exceptions.py new file mode 100644 index 0000000..532dddd --- /dev/null +++ b/pip-platform/tests/unit/test_exceptions.py @@ -0,0 +1,66 @@ +import pytest + +from src.core.exceptions import ( + PIPException, + NotFoundException, + ConflictException, + ValidationException, + UnauthorizedException, + ForbiddenException, + RateLimitException, + ServiceUnavailableException, + ConnectorException, + SyncException, +) + + +class TestPIPException: + def test_base_exception(self): + exc = PIPException("Something failed", code="TEST_ERROR", status_code=500) + assert exc.message == "Something failed" + assert exc.code == "TEST_ERROR" + assert exc.status_code == 500 + assert "timestamp" in exc.to_dict()["error"] + + def test_not_found(self): + exc = NotFoundException("Pharmacy", "PH-001") + assert exc.status_code == 404 + assert exc.code == "NOT_FOUND" + d = exc.to_dict() + assert "Pharmacy" in d["error"]["message"] + + def test_conflict(self): + exc = ConflictException("Duplicate") + assert exc.status_code == 409 + + def test_validation(self): + exc = ValidationException("Invalid input") + assert exc.status_code == 422 + + def test_unauthorized(self): + exc = UnauthorizedException() + assert exc.status_code == 401 + + def test_forbidden(self): + exc = ForbiddenException(required_role="admin") + assert exc.status_code == 403 + assert exc.details["required_role"] == "admin" + + def test_rate_limit(self): + exc = RateLimitException() + assert exc.status_code == 429 + + def test_service_unavailable(self): + exc = ServiceUnavailableException("redis") + assert exc.status_code == 503 + assert "redis" in exc.details["service"] + + def test_connector_error(self): + exc = ConnectorException("farmatic", "Connection refused") + assert exc.status_code == 502 + assert exc.details["connector_type"] == "farmatic" + + def test_sync_error(self): + exc = SyncException("pharmacy-1", "Timeout") + assert exc.status_code == 502 + assert exc.details["pharmacy_id"] == "pharmacy-1" diff --git a/pip-platform/tests/unit/test_mock_connector.py b/pip-platform/tests/unit/test_mock_connector.py new file mode 100644 index 0000000..1aa3e17 --- /dev/null +++ b/pip-platform/tests/unit/test_mock_connector.py @@ -0,0 +1,138 @@ +import asyncio +from uuid import UUID, uuid4 + +import pytest + +from src.connectors.mock.mock_connector import MockConnector +from src.connectors.base import ConnectorResult +from src.core.exceptions import ConnectorException + + +@pytest.fixture +def mock_connector() -> MockConnector: + return MockConnector() + + +@pytest.fixture +def pharmacy_id() -> UUID: + return uuid4() + + +@pytest.fixture +def medication_id() -> UUID: + return uuid4() + + +class TestMockConnectorConfiguration: + def test_default_configuration(self, mock_connector: MockConnector): + assert mock_connector._simulate_latency is True + assert mock_connector._error_rate == 0.0 + + def test_configure_latency(self, mock_connector: MockConnector): + mock_connector.configure({"simulate_latency": False, "latency_range_ms": [5, 10]}) + assert mock_connector._simulate_latency is False + assert mock_connector._latency_range_ms == (5, 10) + + def test_configure_error_rate(self, mock_connector: MockConnector): + mock_connector.configure({"error_rate": 0.5}) + assert mock_connector._error_rate == 0.5 + + +class TestMockConnectorGetStock: + @pytest.mark.asyncio + async def test_get_stock_all(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.get_stock(pharmacy_id) + assert result.success is True + assert "items" in result.data + assert result.data["total"] == 10 + + @pytest.mark.asyncio + async def test_get_stock_by_nregistro(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.get_stock(pharmacy_id, nregistro="00001") + assert result.success is True + assert result.data["nregistro"] == "00001" + assert result.data["name"] == "Ibuprofeno 600mg" + + @pytest.mark.asyncio + async def test_get_stock_not_found(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.get_stock(pharmacy_id, nregistro="99999") + assert result.success is False + assert "not found" in result.error + + +class TestMockConnectorReserve: + @pytest.mark.asyncio + async def test_reserve(self, mock_connector: MockConnector, pharmacy_id: UUID, medication_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.reserve(pharmacy_id, medication_id, 5) + assert result.success is True + assert result.data["status"] == "confirmed" + assert result.data["quantity"] == 5 + + @pytest.mark.asyncio + async def test_reserve_idempotency(self, mock_connector: MockConnector, pharmacy_id: UUID, medication_id: UUID): + mock_connector.configure({"simulate_latency": False}) + key = "idem-key-123" + result1 = await mock_connector.reserve(pharmacy_id, medication_id, 3, idempotency_key=key) + result2 = await mock_connector.reserve(pharmacy_id, medication_id, 3, idempotency_key=key) + assert result1.success is True + assert result2.success is True + + +class TestMockConnectorCancelReservation: + @pytest.mark.asyncio + async def test_cancel_reservation(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + reserve_result = await mock_connector.reserve(pharmacy_id, uuid4(), 2) + reservation_id = reserve_result.data["reservation_id"] + result = await mock_connector.cancel_reservation(pharmacy_id, reservation_id) + assert result.success is True + assert result.data["status"] == "cancelled" + + @pytest.mark.asyncio + async def test_cancel_nonexistent_reservation(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.cancel_reservation(pharmacy_id, "nonexistent-id") + assert result.success is False + + +class TestMockConnectorPharmacyInfo: + @pytest.mark.asyncio + async def test_pharmacy_information(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False}) + result = await mock_connector.pharmacy_information(pharmacy_id) + assert result.success is True + assert "FARM-001" in result.data["code"] + + +class TestMockConnectorSynchronize: + @pytest.mark.asyncio + async def test_synchronize(self, mock_connector: MockConnector, pharmacy_id: UUID): + mock_connector.configure({"simulate_latency": False, "error_rate": 0.0}) + result = await mock_connector.synchronize(pharmacy_id, strategy="full") + assert result.success is True + assert result.data["items_total"] == 10 + assert result.data["items_failed"] == 0 + + +class TestMockConnectorHeartbeat: + @pytest.mark.asyncio + async def test_heartbeat(self, mock_connector: MockConnector, pharmacy_id: UUID): + result = await mock_connector.heartbeat(pharmacy_id) + assert result.success is True + assert result.data["status"] == "healthy" + assert result.data["connector_type"] == "mock" + + +class TestMockConnectorErrorSimulation: + def test_high_error_rate_triggers_exception(self, mock_connector: MockConnector): + mock_connector.configure({"error_rate": 1.0}) + with pytest.raises(ConnectorException): + mock_connector._maybe_error() + + def test_zero_error_rate_no_exception(self, mock_connector: MockConnector): + mock_connector.configure({"error_rate": 0.0}) + mock_connector._maybe_error() -- 2.52.0 From 71a34293a9b6ccf21e5eb22bbeefd9665dc5ad93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antoni=20Nu=C3=B1ez=20Romeu?= Date: Wed, 1 Jul 2026 17:08:15 +0200 Subject: [PATCH 3/3] Views in frontend changed --- frontend/src/App.css | 7 +- frontend/src/components/BottomNav.css | 234 +++++++++++++------------- frontend/src/components/TopBar.css | 112 ++++++------ 3 files changed, 169 insertions(+), 184 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index f4f2fd2..58ccf16 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -11,12 +11,7 @@ flex: 1; overflow-y: auto; overscroll-behavior: contain; -} - -@media (max-width: 768px) { - .app-content { - padding-bottom: calc(6rem + env(safe-area-inset-bottom)); - } + padding-bottom: calc(6rem + env(safe-area-inset-bottom)); } @keyframes fadeInUp { diff --git a/frontend/src/components/BottomNav.css b/frontend/src/components/BottomNav.css index 3e301f1..d221231 100644 --- a/frontend/src/components/BottomNav.css +++ b/frontend/src/components/BottomNav.css @@ -1,123 +1,119 @@ .bottom-nav { - display: none; + display: flex; + justify-content: space-around; + align-items: flex-end; + position: fixed; + left: 50%; + transform: translateX(-50%); + bottom: 0; + width: 100%; + max-width: 48rem; + z-index: 50; + padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom)); + background: var(--surface-container-lowest); + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + height: 5.5rem; } -@media (max-width: 768px) { - .bottom-nav { - display: flex; - justify-content: space-around; - align-items: flex-end; - position: fixed; - left: 0; - right: 0; - bottom: 0; - z-index: 50; - padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom)); - background: var(--surface-container-lowest); - box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1); - border-radius: var(--radius-lg) var(--radius-lg) 0 0; - height: 5.5rem; - } - - .bottom-nav-item { - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-end; - gap: 0.15rem; - padding: 0.25rem 0.5rem; - background: transparent; - border: none; - cursor: pointer; - color: var(--on-surface-variant); - font: inherit; - -webkit-tap-highlight-color: transparent; - transition: color 0.15s; - position: relative; - min-width: 3.5rem; - } - - .bottom-nav-item.disabled { - opacity: 0.4; - cursor: not-allowed; - } - - .nav-icon-wrap { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.75rem; - height: 2.75rem; - border-radius: var(--radius-full); - color: inherit; - position: relative; - } - - .nav-fab { - display: inline-flex; - align-items: center; - justify-content: center; - width: 3.5rem; - height: 3.5rem; - border-radius: var(--radius-full); - background: var(--tertiary-container); - color: var(--on-tertiary); - box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35); - margin-top: -1.5rem; - transition: transform 0.15s; - } - - .nav-fab:active { - transform: scale(0.92); - } - - .nav-badge { - position: absolute; - top: 0; - right: -2px; - width: 1.125rem; - height: 1.125rem; - background: var(--error); - color: var(--on-error); - font-size: 0.625rem; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-full); - border: 2px solid var(--surface-container-lowest); - line-height: 1; - } - - .nav-label { - font-size: 0.7rem; - font-weight: 500; - line-height: 1; - color: var(--on-surface-variant); - } - - .nav-label--active { - color: var(--primary); - font-weight: 700; - } - - .nav-indicator { - width: 0.375rem; - height: 0.375rem; - background: var(--primary); - border-radius: var(--radius-full); - margin-top: 0.125rem; - } - - .bottom-nav-item.active .nav-icon-wrap { - color: var(--primary); - } - - .bottom-nav-item.active .nav-icon-wrap svg { - stroke-width: 2.5; - } - - .nav-elevated.active .nav-label { - color: var(--tertiary); - } +.bottom-nav-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: 0.15rem; + padding: 0.25rem 0.5rem; + background: transparent; + border: none; + cursor: pointer; + color: var(--on-surface-variant); + font: inherit; + -webkit-tap-highlight-color: transparent; + transition: color 0.15s; + position: relative; + min-width: 3.5rem; +} + +.bottom-nav-item.disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.nav-icon-wrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border-radius: var(--radius-full); + color: inherit; + position: relative; +} + +.nav-fab { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.5rem; + height: 3.5rem; + border-radius: var(--radius-full); + background: var(--tertiary-container); + color: var(--on-tertiary); + box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35); + margin-top: -1.5rem; + transition: transform 0.15s; +} + +.nav-fab:active { + transform: scale(0.92); +} + +.nav-badge { + position: absolute; + top: 0; + right: -2px; + width: 1.125rem; + height: 1.125rem; + background: var(--error); + color: var(--on-error); + font-size: 0.625rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + border: 2px solid var(--surface-container-lowest); + line-height: 1; +} + +.nav-label { + font-size: 0.7rem; + font-weight: 500; + line-height: 1; + color: var(--on-surface-variant); +} + +.nav-label--active { + color: var(--primary); + font-weight: 700; +} + +.nav-indicator { + width: 0.375rem; + height: 0.375rem; + background: var(--primary); + border-radius: var(--radius-full); + margin-top: 0.125rem; +} + +.bottom-nav-item.active .nav-icon-wrap { + color: var(--primary); +} + +.bottom-nav-item.active .nav-icon-wrap svg { + stroke-width: 2.5; +} + +.nav-elevated.active .nav-label { + color: var(--tertiary); } diff --git a/frontend/src/components/TopBar.css b/frontend/src/components/TopBar.css index 36e0db7..ce82413 100644 --- a/frontend/src/components/TopBar.css +++ b/frontend/src/components/TopBar.css @@ -1,63 +1,57 @@ .top-bar { - display: none; + display: block; + position: sticky; + top: 0; + z-index: 40; + background: var(--surface); + box-shadow: 0 1px 3px rgba(0,0,0,0.06); } -@media (max-width: 768px) { - .top-bar { - display: block; - position: sticky; - top: 0; - z-index: 40; - background: var(--surface); - box-shadow: 0 1px 3px rgba(0,0,0,0.06); - } - - .topbar-inner { - display: flex; - align-items: center; - justify-content: space-between; - height: var(--touch-target-min); - padding: 0 var(--margin-main); - max-width: 48rem; - margin: 0 auto; - } - - .topbar-back { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - border-radius: var(--radius-full); - border: none; - background: transparent; - color: var(--primary); - cursor: pointer; - transition: background 0.15s; - } - - .topbar-back:hover { - background: var(--surface-container-high); - } - - .topbar-title { - font-size: 1.15rem; - font-weight: 700; - color: var(--primary); - letter-spacing: -0.01em; - } - - .topbar-logo-img { - height: 2rem; - width: auto; - } - - .topbar-spacer { - width: 2.5rem; - } - - .topbar-right { - display: flex; - align-items: center; - } +.topbar-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--touch-target-min); + padding: 0 var(--margin-main); + max-width: 48rem; + margin: 0 auto; +} + +.topbar-back { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius-full); + border: none; + background: transparent; + color: var(--primary); + cursor: pointer; + transition: background 0.15s; +} + +.topbar-back:hover { + background: var(--surface-container-high); +} + +.topbar-title { + font-size: 1.15rem; + font-weight: 700; + color: var(--primary); + letter-spacing: -0.01em; +} + +.topbar-logo-img { + height: 2rem; + width: auto; +} + +.topbar-spacer { + width: 2.5rem; +} + +.topbar-right { + display: flex; + align-items: center; } -- 2.52.0