diff --git a/.env b/.env
new file mode 100644
index 0000000000000000000000000000000000000000..83d48e11ae2e47df6c8f7736b60ac28108af874f
--- /dev/null
+++ b/.env
@@ -0,0 +1,51 @@
+ELASTIC_VERSION=8.11.3
+
+## Passwords for stack users
+#
+
+# User 'elastic' (built-in)
+#
+# Superuser role, full access to cluster management and data indices.
+# https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html
+ELASTIC_PASSWORD='mobilespassword'
+
+# User 'logstash_internal' (custom)
+#
+# The user Logstash uses to connect and send data to Elasticsearch.
+# https://www.elastic.co/guide/en/logstash/current/ls-security.html
+LOGSTASH_INTERNAL_PASSWORD='mobilespassword'
+
+# User 'kibana_system' (built-in)
+#
+# The user Kibana uses to connect and communicate with Elasticsearch.
+# https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html
+KIBANA_SYSTEM_PASSWORD='mobilespassword'
+
+# Users 'metricbeat_internal', 'filebeat_internal' and 'heartbeat_internal' (custom)
+#
+# The users Beats use to connect and send data to Elasticsearch.
+# https://www.elastic.co/guide/en/beats/metricbeat/current/feature-roles.html
+METRICBEAT_INTERNAL_PASSWORD=''
+FILEBEAT_INTERNAL_PASSWORD=''
+HEARTBEAT_INTERNAL_PASSWORD=''
+
+# User 'monitoring_internal' (custom)
+#
+# The user Metricbeat uses to collect monitoring data from stack components.
+# https://www.elastic.co/guide/en/elasticsearch/reference/current/how-monitoring-works.html
+MONITORING_INTERNAL_PASSWORD=''
+
+# User 'beats_system' (built-in)
+#
+# The user the Beats use when storing monitoring information in Elasticsearch.
+# https://www.elastic.co/guide/en/elasticsearch/reference/current/built-in-users.html
+BEATS_SYSTEM_PASSWORD=''
+
+MYSQL_USER='mobiles'
+MYSQL_PASSWORD='mobilespassword'
+MYSQL_ROOT_PASSWORD='mobilespassword'
+MYSQL_DB='mobiles'
+
+KTBS_USER='mobiles'
+KTBS_PASSWORD='mobilespassword'
+KTBS_URL='https://KTBS_URL/tracesmobiles/@obsels'
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..07bf023f193e743e0c59ac8d56d90a8a3d0e29b8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,61 @@
+# Python
+**/__pycache__/
+**/*.py[cod]
+**/*.pyo
+**/*.pyd
+**/.Python
+**/env/
+**/venv/
+**/ENV/
+**/env.bak/
+**/venv.bak/
+**/*.egg-info/
+**/*.eggs/
+**/dist/
+**/build/
+**/*.egg
+
+# Docker
+# Ignore Docker-related files
+.docker/
+docker-compose.override.yml
+*.dockerignore
+
+# VSCode
+.vscode/
+.history/
+.vsconfig
+
+# Log files
+**/*.log
+
+# Secrets
+# **/*.env
+# **/*.local
+# **/secrets/
+
+# Jupyter Notebook Checkpoints
+**/.ipynb_checkpoints/
+
+# Coverage reports
+**/.coverage
+**/*.cover
+**/*.py,cover
+
+# Miscellaneous
+**/*.DS_Store
+**/Thumbs.db
+**/*.tmp
+**/*.bak
+**/*.swp
+**/*.swo
+**/*.swn
+**/*.orig
+
+# Specific IDE files
+**/.idea/
+**/*.sublime-workspace
+**/*.sublime-project
+
+# Ignore files generated by mypy
+**/.mypy_cache/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/db/create_scheduled_task.sql b/db/create_scheduled_task.sql
new file mode 100644
index 0000000000000000000000000000000000000000..9f23e702ae2314f7036fca271cbfba745c9277c3
--- /dev/null
+++ b/db/create_scheduled_task.sql
@@ -0,0 +1,17 @@
+-- Crée la base de données si elle n'existe pas
+CREATE DATABASE IF NOT EXISTS mobiles;
+
+-- Utilise la base de données mobiles
+USE mobiles;
+
+-- Crée la table scheduled_task
+CREATE TABLE IF NOT EXISTS scheduled_task (
+    id INT PRIMARY KEY AUTO_INCREMENT,
+    name VARCHAR(50) NOT NULL,
+    action VARCHAR(50) NOT NULL,
+    scheduled_time DATETIME NOT NULL,
+    creation_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    status VARCHAR(20) NOT NULL DEFAULT 'Not Executed',
+    last_run DATETIME NULL,
+    recurrence VARCHAR(50) NULL
+);
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9342a24b1729801a032772167c9378c7683ef76e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,126 @@
+version: '3.7'
+
+services:
+
+  setup:
+    profiles:
+      - setup
+    build:
+      context: setup/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    init: true
+    volumes:
+      - ./setup/entrypoint.sh:/entrypoint.sh:ro,Z
+      - ./setup/lib.sh:/lib.sh:ro,Z
+      - ./setup/roles:/roles:ro,Z
+    environment:
+      ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
+      LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-}
+      KIBANA_SYSTEM_PASSWORD: ${KIBANA_SYSTEM_PASSWORD:-}
+      METRICBEAT_INTERNAL_PASSWORD: ${METRICBEAT_INTERNAL_PASSWORD:-}
+      FILEBEAT_INTERNAL_PASSWORD: ${FILEBEAT_INTERNAL_PASSWORD:-}
+      HEARTBEAT_INTERNAL_PASSWORD: ${HEARTBEAT_INTERNAL_PASSWORD:-}
+      MONITORING_INTERNAL_PASSWORD: ${MONITORING_INTERNAL_PASSWORD:-}
+      BEATS_SYSTEM_PASSWORD: ${BEATS_SYSTEM_PASSWORD:-}
+    networks:
+      - mobnet
+    depends_on:
+      - elasticsearch
+
+  elasticsearch:
+    container_name: es
+    build:
+      context: elasticsearch/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    volumes:
+      - ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml:ro,Z
+      - elasticsearch:/usr/share/elasticsearch/data:Z
+      - ./elasticsearch/config/index-template.json:/usr/share/elasticsearch/config/index-template.json
+    ports:
+      - 9200:9200
+      - 9300:9300
+    environment:
+      node.name: elasticsearch
+      LS_JAVA_OPTS: -Xms2g -Xmx2g # Increase heap size to 1 GB
+      # Bootstrap password.
+      # Used to initialize the keystore during the initial startup of
+      # Elasticsearch. Ignored on subsequent runs.
+      ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
+      # Use single node discovery in order to disable production mode and avoid bootstrap checks.
+      # see: https://www.elastic.co/guide/en/elasticsearch/reference/current/bootstrap-checks.html
+      discovery.type: single-node
+    networks:
+      - mobnet
+    restart: unless-stopped
+
+  logstash:
+    container_name: stash
+    build:
+      context: logstash/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    volumes:
+      - ./logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:Z
+      - ./logstash/config/pipeline:/usr/share/logstash/pipeline:Z
+      - ./logstash/config/index-template.json:/usr/share/logstash/config/index-template.json
+      - ./logstash/config/mysql-connector-j-8.2.0.jar:/usr/share/logstash/config/mysql-connector-j-8.2.0.jar
+      - ./logstash/ruby/:/usr/share/logstash/ruby/
+    ports:
+      - 5044:5044
+      - 50000:50000/tcp
+      - 50000:50000/udp
+      - 9600:9600
+    environment:
+      LS_JAVA_OPTS: -Xms2g -Xmx2g # Increase heap size to 2 GB
+      LOGSTASH_INTERNAL_PASSWORD: ${LOGSTASH_INTERNAL_PASSWORD:-}
+      KTBS_URL: ${KTBS_URL:-}
+      KTBS_USER: ${KTBS_USER:-}
+      KTBS_PASSWORD: ${KTBS_PASSWORD:-}
+      MYSQL_DB: ${MYSQL_DB}
+      MYSQL_USER: ${MYSQL_USER:-}
+      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}
+      LOG_LEVEL: debug
+    networks:
+      - mobnet
+    depends_on:
+      - elasticsearch
+    # restart: unless-stopped
+
+  
+
+  db:
+    container_name: db
+    image: mariadb:latest
+    environment:
+      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-}
+      MYSQL_DATABASE: ${MYSQL_DB}
+      MYSQL_USER: ${MYSQL_USER:-}
+      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}
+    volumes:
+      - db_data:/var/lib/mysql
+      - ./db:/docker-entrypoint-initdb.d
+    networks:
+      - mobnet
+    ports:
+      - "3306:3306"
+  
+  rs:
+    container_name: rs
+    build: ./rs
+    volumes:
+      - ./rs/config:/app/config  
+    networks:
+      - mobnet  
+    ports:
+      - "8080:8080"
+
+
+networks:
+  mobnet:
+    driver: bridge
+
+volumes:
+  elasticsearch:
+  db_data:
diff --git a/elasticsearch/Dockerfile b/elasticsearch/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..823ea2486e17a3440b82ff861eb699397757339e
--- /dev/null
+++ b/elasticsearch/Dockerfile
@@ -0,0 +1,8 @@
+ARG ELASTIC_VERSION
+
+# https://www.docker.elastic.co/
+FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION}
+
+
+# Add your elasticsearch plugins setup here
+# Example: RUN elasticsearch-plugin install analysis-icu
diff --git a/elasticsearch/config/elasticsearch.yml b/elasticsearch/config/elasticsearch.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4243d38ebe35a01e5f9a8402f1568a5ae4e1782f
--- /dev/null
+++ b/elasticsearch/config/elasticsearch.yml
@@ -0,0 +1,12 @@
+---
+## Default Elasticsearch configuration from Elasticsearch base image.
+## https://github.com/elastic/elasticsearch/blob/main/distribution/docker/src/docker/config/elasticsearch.yml
+#
+cluster.name: docker-cluster
+network.host: 0.0.0.0
+
+## X-Pack settings
+## see https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html
+#
+xpack.license.self_generated.type: basic
+xpack.security.enabled: true
diff --git a/elasticsearch/config/index-template.json b/elasticsearch/config/index-template.json
new file mode 100644
index 0000000000000000000000000000000000000000..87351689bb6aed8721ab9b98ad72cba56eef08c7
--- /dev/null
+++ b/elasticsearch/config/index-template.json
@@ -0,0 +1,19 @@
+{
+  "index_patterns": ["obsels"],
+  "settings": {
+    "number_of_shards": 1
+  },
+  "mappings": {
+    "properties": {
+      "m:coordinates": {
+        "type": "geo_point"
+      },
+      "m:position": {
+        "type": "geo_point"
+      },
+      "m:prev_coordinates": {
+        "type": "geo_point"
+      }
+    }
+  }
+}
diff --git a/extensions/README.md b/extensions/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..50016fb6ce9f9e59367e015baf01c0ac309352c2
--- /dev/null
+++ b/extensions/README.md
@@ -0,0 +1,3 @@
+# Extensions
+
+Third-party extensions that enable extra integrations with the Elastic stack.
diff --git a/extensions/curator/Dockerfile b/extensions/curator/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..6cb8cdc6816d4e10492e779b233cb879ece121e5
--- /dev/null
+++ b/extensions/curator/Dockerfile
@@ -0,0 +1,9 @@
+FROM untergeek/curator:8.0.2
+
+USER root
+
+RUN >>/var/spool/cron/crontabs/nobody \
+    echo '* * * * * /curator/curator /.curator/delete_log_files_curator.yml'
+
+ENTRYPOINT ["crond"]
+CMD ["-f", "-d8"]
diff --git a/extensions/curator/README.md b/extensions/curator/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..5c38786aac5132c1eadbfee706e3791902588d1f
--- /dev/null
+++ b/extensions/curator/README.md
@@ -0,0 +1,20 @@
+# Curator
+
+Elasticsearch Curator helps you curate or manage your indices.
+
+## Usage
+
+If you want to include the Curator extension, run Docker Compose from the root of the repository with an additional
+command line argument referencing the `curator-compose.yml` file:
+
+```bash
+$ docker-compose -f docker-compose.yml -f extensions/curator/curator-compose.yml up
+```
+
+This sample setup demonstrates how to run `curator` every minute using `cron`.
+
+All configuration files are available in the `config/` directory.
+
+## Documentation
+
+[Curator Reference](https://www.elastic.co/guide/en/elasticsearch/client/curator/current/index.html)
diff --git a/extensions/curator/config/curator.yml b/extensions/curator/config/curator.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6777edc9cbfac913de317f749b2b50706ffff08b
--- /dev/null
+++ b/extensions/curator/config/curator.yml
@@ -0,0 +1,13 @@
+# Curator configuration
+# https://www.elastic.co/guide/en/elasticsearch/client/curator/current/configfile.html
+
+elasticsearch:
+  client:
+    hosts: [ http://elasticsearch:9200 ]
+  other_settings:
+    username: elastic
+    password: ${ELASTIC_PASSWORD}
+
+logging:
+  loglevel: INFO
+  logformat: default
diff --git a/extensions/curator/config/delete_log_files_curator.yml b/extensions/curator/config/delete_log_files_curator.yml
new file mode 100644
index 0000000000000000000000000000000000000000..779c67ac0d174ef5da3acaa98dbc4000dfa6a0e0
--- /dev/null
+++ b/extensions/curator/config/delete_log_files_curator.yml
@@ -0,0 +1,21 @@
+actions:
+  1:
+    action: delete_indices
+    description: >-
+      Delete indices. Find which to delete by first limiting the list to
+      logstash- prefixed indices. Then further filter those to prevent deletion
+      of anything less than the number of days specified by unit_count.
+      Ignore the error if the filter does not result in an actionable list of
+      indices (ignore_empty_list) and exit cleanly.
+    options:
+      ignore_empty_list: True
+      disable_action: False
+    filters:
+    - filtertype: pattern
+      kind: prefix
+      value: logstash-
+    - filtertype: age
+      source: creation_date
+      direction: older
+      unit: days
+      unit_count: 2
diff --git a/extensions/curator/curator-compose.yml b/extensions/curator/curator-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3a01b0b27ca9ec67b5feedbe01588e19eece4dc5
--- /dev/null
+++ b/extensions/curator/curator-compose.yml
@@ -0,0 +1,16 @@
+version: '3.7'
+
+services:
+  curator:
+    build:
+      context: extensions/curator/
+    init: true
+    volumes:
+      - ./extensions/curator/config/curator.yml:/.curator/curator.yml:ro,Z
+      - ./extensions/curator/config/delete_log_files_curator.yml:/.curator/delete_log_files_curator.yml:ro,Z
+    environment:
+      ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
+    networks:
+      - elk
+    depends_on:
+      - es
diff --git a/extensions/enterprise-search/Dockerfile b/extensions/enterprise-search/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..4f0752e55adfc7877f41180ee3c0352bbd049e15
--- /dev/null
+++ b/extensions/enterprise-search/Dockerfile
@@ -0,0 +1,4 @@
+ARG ELASTIC_VERSION
+
+# https://www.docker.elastic.co/
+FROM docker.elastic.co/enterprise-search/enterprise-search:${ELASTIC_VERSION}
diff --git a/extensions/enterprise-search/README.md b/extensions/enterprise-search/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..5b849488dba8ed30bdf4470f63c5ea9ec3c32b3f
--- /dev/null
+++ b/extensions/enterprise-search/README.md
@@ -0,0 +1,144 @@
+# Enterprise Search extension
+
+Elastic Enterprise Search is a suite of products for search applications backed by the Elastic Stack.
+
+## Requirements
+
+* 2 GB of free RAM, on top of the resources required by the other stack components and extensions.
+
+The Enterprise Search web application is served on the TCP port `3002`.
+
+## Usage
+
+### Generate an encryption key
+
+Enterprise Search requires one or more [encryption keys][enterprisesearch-encryption] to be configured before the
+initial startup. Failing to do so prevents the server from starting.
+
+Encryption keys can contain any series of characters. Elastic recommends using 256-bit keys for optimal security.
+
+Those encryption keys must be added manually to the [`config/enterprise-search.yml`][config-enterprisesearch] file. By
+default, the list of encryption keys is empty and must be populated using one of the following formats:
+
+```yaml
+secret_management.encryption_keys:
+  - my_first_encryption_key
+  - my_second_encryption_key
+  - ...
+```
+
+```yaml
+secret_management.encryption_keys: [my_first_encryption_key, my_second_encryption_key, ...]
+```
+
+> [!NOTE]
+> To generate a strong random encryption key, you can use the OpenSSL utility or any other online/offline tool of your
+> choice:
+>
+> ```console
+> $ openssl rand -hex 32
+> 680f94e568c90364bedf927b2f0f49609702d3eab9098688585a375b14274546
+> ```
+
+### Enable Elasticsearch's API key service
+
+Enterprise Search requires Elasticsearch's built-in [API key service][es-security] to be enabled in order to start.
+Unless Elasticsearch is configured to enable TLS on the HTTP interface (disabled by default), this service is disabled
+by default.
+
+To enable it, modify the Elasticsearch configuration file in [`elasticsearch/config/elasticsearch.yml`][config-es] and
+add the following setting:
+
+```yaml
+xpack.security.authc.api_key.enabled: true
+```
+
+### Configure the Enterprise Search host in Kibana
+
+Kibana acts as the [management interface][enterprisesearch-kb] to Enterprise Search.
+
+To enable the management experience for Enterprise Search, modify the Kibana configuration file in
+[`kibana/config/kibana.yml`][config-kbn] and add the following setting:
+
+```yaml
+enterpriseSearch.host: http://enterprise-search:3002
+```
+
+### Start the server
+
+To include Enterprise Search in the stack, run Docker Compose from the root of the repository with an additional command
+line argument referencing the `enterprise-search-compose.yml` file:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/enterprise-search/enterprise-search-compose.yml up
+```
+
+Allow a few minutes for the stack to start, then open your web browser at the address <http://localhost:3002> to see the
+Enterprise Search home page.
+
+Enterprise Search is configured on first boot with the following default credentials:
+
+* user: *enterprise_search*
+* password: *mobilespassword*
+
+## Security
+
+The Enterprise Search password is defined inside the Compose file via the `ENT_SEARCH_DEFAULT_PASSWORD` environment
+variable. We highly recommend choosing a more secure password than the default one for security reasons.
+
+To do so, change the value `ENT_SEARCH_DEFAULT_PASSWORD` environment variable inside the Compose file **before the first
+boot**:
+
+```yaml
+enterprise-search:
+
+  environment:
+    ENT_SEARCH_DEFAULT_PASSWORD: {{some strong password}}
+```
+
+> [!WARNING]
+> The default Enterprise Search password can only be set during the initial boot. Once the password is persisted in
+> Elasticsearch, it can only be changed via the Elasticsearch API.
+
+For more information, please refer to [User Management and Security][enterprisesearch-security].
+
+## Configuring Enterprise Search
+
+The Enterprise Search configuration is stored in [`config/enterprise-search.yml`][config-enterprisesearch]. You can
+modify this file using the [Default Enterprise Search configuration][enterprisesearch-config] as a reference.
+
+You can also specify the options you want to override by setting environment variables inside the Compose file:
+
+```yaml
+enterprise-search:
+
+  environment:
+    ent_search.auth.source: standard
+    worker.threads: '6'
+```
+
+Any change to the Enterprise Search configuration requires a restart of the Enterprise Search container:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/enterprise-search/enterprise-search-compose.yml restart enterprise-search
+```
+
+Please refer to the following documentation page for more details about how to configure Enterprise Search inside a
+Docker container: [Running Enterprise Search Using Docker][enterprisesearch-docker].
+
+## See also
+
+[Enterprise Search documentation][enterprisesearch-docs]
+
+[config-enterprisesearch]: ./config/enterprise-search.yml
+
+[enterprisesearch-encryption]: https://www.elastic.co/guide/en/enterprise-search/current/encryption-keys.html
+[enterprisesearch-security]: https://www.elastic.co/guide/en/workplace-search/current/workplace-search-security.html
+[enterprisesearch-config]: https://www.elastic.co/guide/en/enterprise-search/current/configuration.html
+[enterprisesearch-docker]: https://www.elastic.co/guide/en/enterprise-search/current/docker.html
+[enterprisesearch-docs]: https://www.elastic.co/guide/en/enterprise-search/current/index.html
+[enterprisesearch-kb]: https://www.elastic.co/guide/en/kibana/current/enterprise-search-settings-kb.html
+
+[es-security]: https://www.elastic.co/guide/en/elasticsearch/reference/current/security-settings.html#api-key-service-settings
+[config-es]: ../../elasticsearch/config/elasticsearch.yml
+[config-kbn]: ../../kibana/config/kibana.yml
diff --git a/extensions/enterprise-search/config/enterprise-search.yml b/extensions/enterprise-search/config/enterprise-search.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a1f098dd2e116e5ae370c20def35672b295f9f62
--- /dev/null
+++ b/extensions/enterprise-search/config/enterprise-search.yml
@@ -0,0 +1,28 @@
+---
+## Enterprise Search core configuration
+## https://www.elastic.co/guide/en/enterprise-search/current/configuration.html
+#
+
+## --------------------- REQUIRED ---------------------
+
+# Encryption keys to protect application secrets.
+secret_management.encryption_keys:
+  # example:
+  #- 680f94e568c90364bedf927b2f0f49609702d3eab9098688585a375b14274546
+
+## ----------------------------------------------------
+
+# IP address Enterprise Search listens on
+ent_search.listen_host: 0.0.0.0
+
+# URL at which users reach Enterprise Search / Kibana
+ent_search.external_url: http://localhost:3002
+kibana.host: http://localhost:5601
+
+# Elasticsearch URL and credentials
+elasticsearch.host: http://elasticsearch:9200
+elasticsearch.username: elastic
+elasticsearch.password: ${ELASTIC_PASSWORD}
+
+# Allow Enterprise Search to modify Elasticsearch settings. Used to enable auto-creation of Elasticsearch indexes.
+allow_es_settings_modification: true
diff --git a/extensions/enterprise-search/enterprise-search-compose.yml b/extensions/enterprise-search/enterprise-search-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ba034fa61cb5234a0231f5c7c0a4b1724b114672
--- /dev/null
+++ b/extensions/enterprise-search/enterprise-search-compose.yml
@@ -0,0 +1,20 @@
+version: '3.7'
+
+services:
+  enterprise-search:
+    build:
+      context: extensions/enterprise-search/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    volumes:
+      - ./extensions/enterprise-search/config/enterprise-search.yml:/usr/share/enterprise-search/config/enterprise-search.yml:ro,Z
+    environment:
+      JAVA_OPTS: -Xms2g -Xmx2g
+      ENT_SEARCH_DEFAULT_PASSWORD: 'mobilespassword'
+      ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:-}
+    ports:
+      - 3002:3002
+    networks:
+      - elk
+    depends_on:
+      - es
diff --git a/extensions/filebeat/Dockerfile b/extensions/filebeat/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..b8dd5f3f5a4e259b3072bb27761e610348a02484
--- /dev/null
+++ b/extensions/filebeat/Dockerfile
@@ -0,0 +1,3 @@
+ARG ELASTIC_VERSION
+
+FROM docker.elastic.co/beats/filebeat:${ELASTIC_VERSION}
diff --git a/extensions/filebeat/README.md b/extensions/filebeat/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f2bfd206082ad5b15d6a4e3d3be945bf5fdac0d0
--- /dev/null
+++ b/extensions/filebeat/README.md
@@ -0,0 +1,42 @@
+# Filebeat
+
+Filebeat is a lightweight shipper for forwarding and centralizing log data. Installed as an agent on your servers,
+Filebeat monitors the log files or locations that you specify, collects log events, and forwards them either to
+Elasticsearch or Logstash for indexing.
+
+## Usage
+
+**This extension requires the `filebeat_internal` and `beats_system` users to be created and initialized with a
+password.** In case you haven't done that during the initial startup of the stack, please refer to [How to re-execute
+the setup][setup] to run the setup container again and initialize these users.
+
+To include Filebeat in the stack, run Docker Compose from the root of the repository with an additional command line
+argument referencing the `filebeat-compose.yml` file:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/filebeat/filebeat-compose.yml up
+```
+
+## Configuring Filebeat
+
+The Filebeat configuration is stored in [`config/filebeat.yml`](./config/filebeat.yml). You can modify this file with
+the help of the [Configuration reference][filebeat-config].
+
+Any change to the Filebeat configuration requires a restart of the Filebeat container:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/filebeat/filebeat-compose.yml restart filebeat
+```
+
+Please refer to the following documentation page for more details about how to configure Filebeat inside a Docker
+container: [Run Filebeat on Docker][filebeat-docker].
+
+## See also
+
+[Filebeat documentation][filebeat-doc]
+
+[filebeat-config]: https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-reference-yml.html
+[filebeat-docker]: https://www.elastic.co/guide/en/beats/filebeat/current/running-on-docker.html
+[filebeat-doc]: https://www.elastic.co/guide/en/beats/filebeat/current/index.html
+
+[setup]: ../../README.md#how-to-re-execute-the-setup
diff --git a/extensions/filebeat/config/filebeat.yml b/extensions/filebeat/config/filebeat.yml
new file mode 100644
index 0000000000000000000000000000000000000000..da8e2ea39fb49c7f2a665c63dad2959403fc85af
--- /dev/null
+++ b/extensions/filebeat/config/filebeat.yml
@@ -0,0 +1,39 @@
+## Filebeat configuration
+## https://github.com/elastic/beats/blob/main/deploy/docker/filebeat.docker.yml
+#
+
+name: filebeat
+
+filebeat.config:
+  modules:
+    path: ${path.config}/modules.d/*.yml
+    reload.enabled: false
+
+filebeat.autodiscover:
+  providers:
+    # The Docker autodiscover provider automatically retrieves logs from Docker
+    # containers as they start and stop.
+    - type: docker
+      hints.enabled: true
+
+processors:
+  - add_cloud_metadata: ~
+
+monitoring:
+  enabled: true
+  elasticsearch:
+    username: beats_system
+    password: ${BEATS_SYSTEM_PASSWORD}
+
+output.elasticsearch:
+  hosts: [ http://elasticsearch:9200 ]
+  username: filebeat_internal
+  password: ${FILEBEAT_INTERNAL_PASSWORD}
+
+## HTTP endpoint for health checking
+## https://www.elastic.co/guide/en/beats/filebeat/current/http-endpoint.html
+#
+
+http:
+  enabled: true
+  host: 0.0.0.0
diff --git a/extensions/filebeat/filebeat-compose.yml b/extensions/filebeat/filebeat-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..445e80916302adb154ddfe89bd86c3e222d17bb5
--- /dev/null
+++ b/extensions/filebeat/filebeat-compose.yml
@@ -0,0 +1,35 @@
+version: '3.7'
+
+services:
+  filebeat:
+    build:
+      context: extensions/filebeat/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    # Run as 'root' instead of 'filebeat' (uid 1000) to allow reading
+    # 'docker.sock' and the host's filesystem.
+    user: root
+    command:
+      # Log to stderr.
+      - -e
+      # Disable config file permissions checks. Allows mounting
+      # 'config/filebeat.yml' even if it's not owned by root.
+      # see: https://www.elastic.co/guide/en/beats/libbeat/current/config-file-permissions.html
+      - --strict.perms=false
+    volumes:
+      - ./extensions/filebeat/config/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro,Z
+      - type: bind
+        source: /var/lib/docker/containers
+        target: /var/lib/docker/containers
+        read_only: true
+      - type: bind
+        source: /var/run/docker.sock
+        target: /var/run/docker.sock
+        read_only: true
+    environment:
+      FILEBEAT_INTERNAL_PASSWORD: ${FILEBEAT_INTERNAL_PASSWORD:-}
+      BEATS_SYSTEM_PASSWORD: ${BEATS_SYSTEM_PASSWORD:-}
+    networks:
+      - elk
+    depends_on:
+      - es
diff --git a/extensions/fleet/Dockerfile b/extensions/fleet/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..0b5a691dd0d00070ca44c05fc1cea9bed3381439
--- /dev/null
+++ b/extensions/fleet/Dockerfile
@@ -0,0 +1,8 @@
+ARG ELASTIC_VERSION
+
+FROM docker.elastic.co/beats/elastic-agent:${ELASTIC_VERSION}
+
+# Ensure the 'state' directory exists and is owned by the 'elastic-agent' user,
+# otherwise mounting a named volume in that location creates a directory owned
+# by root:root which the 'elastic-agent' user isn't allowed to write to.
+RUN mkdir state
diff --git a/extensions/fleet/README.md b/extensions/fleet/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..d1cce04b577ceb9f7d179ebc42be82107738b46a
--- /dev/null
+++ b/extensions/fleet/README.md
@@ -0,0 +1,69 @@
+# Fleet Server
+
+> [!WARNING]
+> This extension currently exists for preview purposes and should be considered **EXPERIMENTAL**. Expect regular changes
+> to the default Fleet settings, both in the Elastic Agent and Kibana.
+>
+> See [Known Issues](#known-issues) for a list of issues that need to be addressed before this extension can be
+> considered functional.
+
+Fleet provides central management capabilities for [Elastic Agents][fleet-doc] via an API and web UI served by Kibana,
+with Elasticsearch acting as the communication layer.
+Fleet Server is the central component which allows connecting Elastic Agents to the Fleet.
+
+## Requirements
+
+The Fleet Server exposes the TCP port `8220` for Agent to Server communications.
+
+## Usage
+
+To include Fleet Server in the stack, run Docker Compose from the root of the repository with an additional command line
+argument referencing the `fleet-compose.yml` file:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/fleet/fleet-compose.yml up
+```
+
+## Configuring Fleet Server
+
+Fleet Server — like any Elastic Agent — is configured via [Agent Policies][fleet-pol] which can be either managed
+through the Fleet management UI in Kibana, or statically pre-configured inside the Kibana configuration file.
+
+To ease the enrollment of Fleet Server in this extension, docker-elk comes with a pre-configured Agent Policy for Fleet
+Server defined inside [`kibana/config/kibana.yml`][config-kbn].
+
+Please refer to the following documentation page for more details about configuring Fleet Server through the Fleet
+management UI: [Fleet UI Settings][fleet-cfg].
+
+## Known Issues
+
+- Logs and metrics are only collected within the Fleet Server's container. Ultimately, we want to emulate the behaviour
+  of the existing Metricsbeat and Filebeat extensions, and collect logs and metrics from all ELK containers
+  out-of-the-box. Unfortunately, this kind of use-case isn't (yet) well supported by Fleet, and most advanced
+  configurations currently require running Elastic Agents in [standalone mode][fleet-standalone].
+  (Relevant resource: [Migrate from Beats to Elastic Agent][fleet-beats])
+- The Elastic Agent auto-enrolls using the `elastic` super-user. With this approach, you do not need to generate a
+  service token — either using the Fleet management UI or [CLI utility][es-svc-token] — prior to starting this
+  extension. However convenient that is, this approach _does not follow security best practices_, and we recommend
+  generating a service token for Fleet Server instead.
+
+## See also
+
+[Fleet and Elastic Agent Guide][fleet-doc]
+
+## Screenshots
+
+![fleet-agents](https://user-images.githubusercontent.com/3299086/202701399-27518fe4-17b7-49d1-aefb-868dffeaa68a.png
+"Fleet Agents")
+![elastic-agent-dashboard](https://user-images.githubusercontent.com/3299086/202701404-958f8d80-a7a0-4044-bbf9-bf73f3bdd17a.png
+"Elastic Agent Dashboard")
+
+[fleet-doc]: https://www.elastic.co/guide/en/fleet/current/fleet-overview.html
+[fleet-pol]: https://www.elastic.co/guide/en/fleet/current/agent-policy.html
+[fleet-cfg]: https://www.elastic.co/guide/en/fleet/current/fleet-settings.html
+
+[config-kbn]: ../../kibana/config/kibana.yml
+
+[fleet-standalone]: https://www.elastic.co/guide/en/fleet/current/elastic-agent-configuration.html
+[fleet-beats]: https://www.elastic.co/guide/en/fleet/current/migrate-beats-to-agent.html
+[es-svc-token]: https://www.elastic.co/guide/en/elasticsearch/reference/current/service-tokens-command.html
diff --git a/extensions/fleet/agent-apmserver-compose.yml b/extensions/fleet/agent-apmserver-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..660301a2489a23fd817c2a218a1a8effde413a6a
--- /dev/null
+++ b/extensions/fleet/agent-apmserver-compose.yml
@@ -0,0 +1,45 @@
+version: '3.7'
+
+# Example of Fleet-enrolled Elastic Agent pre-configured with an agent policy
+# for running the APM Server integration (see kibana.yml).
+#
+# Run with
+#   docker-compose \
+#     -f docker-compose.yml \
+#     -f extensions/fleet/fleet-compose.yml \
+#     -f extensions/fleet/agent-apmserver-compose.yml \
+#     up
+
+services:
+  apm-server:
+    build:
+      context: extensions/fleet/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    volumes:
+      - apm-server:/usr/share/elastic-agent/state:Z
+    environment:
+      FLEET_ENROLL: '1'
+      FLEET_TOKEN_POLICY_NAME: Agent Policy APM Server
+      FLEET_INSECURE: '1'
+      FLEET_URL: http://fleet-server:8220
+      # Enrollment.
+      # (a) Auto-enroll using basic authentication
+      ELASTICSEARCH_USERNAME: elastic
+      ELASTICSEARCH_PASSWORD: ${ELASTIC_PASSWORD:-}
+      # (b) Enroll using a pre-generated enrollment token
+      #FLEET_ENROLLMENT_TOKEN: <enrollment_token>
+    ports:
+      - 8200:8200
+    hostname: apm-server
+    # Elastic Agent does not retry failed connections to Kibana upon the initial enrollment phase.
+    restart: on-failure
+    networks:
+      - elk
+    depends_on:
+      - es
+      - kibana
+      - fleet-server
+
+volumes:
+  apm-server:
diff --git a/extensions/fleet/fleet-compose.yml b/extensions/fleet/fleet-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2a322f700522f53c51974350295dd49fc73ab795
--- /dev/null
+++ b/extensions/fleet/fleet-compose.yml
@@ -0,0 +1,36 @@
+version: '3.7'
+
+services:
+  fleet-server:
+    build:
+      context: extensions/fleet/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    volumes:
+      - fleet-server:/usr/share/elastic-agent/state:Z
+    environment:
+      FLEET_SERVER_ENABLE: '1'
+      FLEET_SERVER_INSECURE_HTTP: '1'
+      FLEET_SERVER_HOST: 0.0.0.0
+      FLEET_SERVER_POLICY_ID: fleet-server-policy
+      # Fleet plugin in Kibana
+      KIBANA_FLEET_SETUP: '1'
+      # Enrollment.
+      # (a) Auto-enroll using basic authentication
+      ELASTICSEARCH_USERNAME: elastic
+      ELASTICSEARCH_PASSWORD: ${ELASTIC_PASSWORD:-}
+      # (b) Enroll using a pre-generated service token
+      #FLEET_SERVER_SERVICE_TOKEN: <service_token>
+    ports:
+      - 8220:8220
+    hostname: fleet-server
+    # Elastic Agent does not retry failed connections to Kibana upon the initial enrollment phase.
+    restart: on-failure
+    networks:
+      - elk
+    depends_on:
+      - es
+      - kibana
+
+volumes:
+  fleet-server:
diff --git a/extensions/heartbeat/Dockerfile b/extensions/heartbeat/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..0d7de1964a8372e0b281b3b6f50dd1f0198bb202
--- /dev/null
+++ b/extensions/heartbeat/Dockerfile
@@ -0,0 +1,3 @@
+ARG ELASTIC_VERSION
+
+FROM docker.elastic.co/beats/heartbeat:${ELASTIC_VERSION}
diff --git a/extensions/heartbeat/README.md b/extensions/heartbeat/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..82c938f5122f0c9df124afe67308d7d66b01fb4b
--- /dev/null
+++ b/extensions/heartbeat/README.md
@@ -0,0 +1,41 @@
+# Heartbeat
+
+Heartbeat is a lightweight daemon that periodically checks the status of your services and determines whether they are
+available.
+
+## Usage
+
+**This extension requires the `heartbeat_internal` and `beats_system` users to be created and initialized with a
+password.** In case you haven't done that during the initial startup of the stack, please refer to [How to re-execute
+the setup][setup] to run the setup container again and initialize these users.
+
+To include Heartbeat in the stack, run Docker Compose from the root of the repository with an additional command line
+argument referencing the `heartbeat-compose.yml` file:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/heartbeat/heartbeat-compose.yml up
+```
+
+## Configuring Heartbeat
+
+The Heartbeat configuration is stored in [`config/heartbeat.yml`](./config/heartbeat.yml). You can modify this file
+with the help of the [Configuration reference][heartbeat-config].
+
+Any change to the Heartbeat configuration requires a restart of the Heartbeat container:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/heartbeat/heartbeat-compose.yml restart heartbeat
+```
+
+Please refer to the following documentation page for more details about how to configure Heartbeat inside a
+Docker container: [Run Heartbeat on Docker][heartbeat-docker].
+
+## See also
+
+[Heartbeat documentation][heartbeat-doc]
+
+[heartbeat-config]: https://www.elastic.co/guide/en/beats/heartbeat/current/heartbeat-reference-yml.html
+[heartbeat-docker]: https://www.elastic.co/guide/en/beats/heartbeat/current/running-on-docker.html
+[heartbeat-doc]: https://www.elastic.co/guide/en/beats/heartbeat/current/index.html
+
+[setup]: ../../README.md#how-to-re-execute-the-setup
diff --git a/extensions/heartbeat/config/heartbeat.yml b/extensions/heartbeat/config/heartbeat.yml
new file mode 100644
index 0000000000000000000000000000000000000000..95e98464deed8b90d271f835fd1715dabb17ccc2
--- /dev/null
+++ b/extensions/heartbeat/config/heartbeat.yml
@@ -0,0 +1,40 @@
+## Heartbeat configuration
+## https://github.com/elastic/beats/blob/main/deploy/docker/heartbeat.docker.yml
+#
+
+name: heartbeat
+
+heartbeat.monitors:
+- type: http
+  schedule: '@every 5s'
+  urls:
+    - http://elasticsearch:9200
+  username: heartbeat_internal
+  password: ${HEARTBEAT_INTERNAL_PASSWORD}
+
+- type: icmp
+  schedule: '@every 5s'
+  hosts:
+    - es
+
+processors:
+- add_cloud_metadata: ~
+
+monitoring:
+  enabled: true
+  elasticsearch:
+    username: beats_system
+    password: ${BEATS_SYSTEM_PASSWORD}
+
+output.elasticsearch:
+  hosts: [ http://elasticsearch:9200 ]
+  username: heartbeat_internal
+  password: ${HEARTBEAT_INTERNAL_PASSWORD}
+
+## HTTP endpoint for health checking
+## https://www.elastic.co/guide/en/beats/heartbeat/current/http-endpoint.html
+#
+
+http:
+  enabled: true
+  host: 0.0.0.0
diff --git a/extensions/heartbeat/heartbeat-compose.yml b/extensions/heartbeat/heartbeat-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aca755f9c5c2da3301865b31cd7b01499d532875
--- /dev/null
+++ b/extensions/heartbeat/heartbeat-compose.yml
@@ -0,0 +1,24 @@
+version: '3.7'
+
+services:
+  heartbeat:
+    build:
+      context: extensions/heartbeat/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    command:
+      # Log to stderr.
+      - -e
+      # Disable config file permissions checks. Allows mounting
+      # 'config/heartbeat.yml' even if it's not owned by root.
+      # see: https://www.elastic.co/guide/en/beats/libbeat/current/config-file-permissions.html
+      - --strict.perms=false
+    volumes:
+      - ./extensions/heartbeat/config/heartbeat.yml:/usr/share/heartbeat/heartbeat.yml:ro,Z
+    environment:
+      HEARTBEAT_INTERNAL_PASSWORD: ${HEARTBEAT_INTERNAL_PASSWORD:-}
+      BEATS_SYSTEM_PASSWORD: ${BEATS_SYSTEM_PASSWORD:-}
+    networks:
+      - elk
+    depends_on:
+      - es
diff --git a/extensions/logspout/Dockerfile b/extensions/logspout/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..9591df53b06f5a7920216b1f8e59e478d796a98e
--- /dev/null
+++ b/extensions/logspout/Dockerfile
@@ -0,0 +1,5 @@
+# uses ONBUILD instructions described here:
+# https://github.com/gliderlabs/logspout/tree/master/custom
+
+FROM gliderlabs/logspout:master
+ENV SYSLOG_FORMAT rfc3164
diff --git a/extensions/logspout/README.md b/extensions/logspout/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..2e34648568eb4259e01aa7624c1dd0f9161bb903
--- /dev/null
+++ b/extensions/logspout/README.md
@@ -0,0 +1,28 @@
+# Logspout extension
+
+Logspout collects all Docker logs using the Docker logs API, and forwards them to Logstash without any additional
+configuration.
+
+## Usage
+
+If you want to include the Logspout extension, run Docker Compose from the root of the repository with an additional
+command line argument referencing the `logspout-compose.yml` file:
+
+```bash
+$ docker-compose -f docker-compose.yml -f extensions/logspout/logspout-compose.yml up
+```
+
+In your Logstash pipeline configuration, enable the `udp` input and set the input codec to `json`:
+
+```logstash
+input {
+  udp {
+    port  => 50000
+    codec => json
+  }
+}
+```
+
+## Documentation
+
+<https://github.com/looplab/logspout-logstash>
diff --git a/extensions/logspout/build.sh b/extensions/logspout/build.sh
new file mode 100644
index 0000000000000000000000000000000000000000..c3ff938845104d9312c96b5d7ea6dd399ceabfa5
--- /dev/null
+++ b/extensions/logspout/build.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+# source: https://github.com/gliderlabs/logspout/blob/621524e/custom/build.sh
+
+set -e
+apk add --update go build-base git mercurial ca-certificates
+cd /src
+go build -ldflags "-X main.Version=$1" -o /bin/logspout
+apk del go git mercurial build-base
+rm -rf /root/go /var/cache/apk/*
+
+# backwards compatibility
+ln -fs /tmp/docker.sock /var/run/docker.sock
diff --git a/extensions/logspout/logspout-compose.yml b/extensions/logspout/logspout-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8af149df3671df5357bbde035faa4fa649ffdbaf
--- /dev/null
+++ b/extensions/logspout/logspout-compose.yml
@@ -0,0 +1,19 @@
+version: '3.7'
+
+services:
+  logspout:
+    build:
+      context: extensions/logspout
+    volumes:
+      - type: bind
+        source: /var/run/docker.sock
+        target: /var/run/docker.sock
+        read_only: true
+    environment:
+      ROUTE_URIS: logstash://logstash:50000
+      LOGSTASH_TAGS: docker-elk
+    networks:
+      - elk
+    depends_on:
+      - logstash
+    restart: on-failure
diff --git a/extensions/logspout/modules.go b/extensions/logspout/modules.go
new file mode 100644
index 0000000000000000000000000000000000000000..f1a22586413d137d506b4e4919f73c017f7df827
--- /dev/null
+++ b/extensions/logspout/modules.go
@@ -0,0 +1,10 @@
+package main
+
+// installs the Logstash adapter for Logspout, and required dependencies
+// https://github.com/looplab/logspout-logstash
+import (
+	_ "github.com/gliderlabs/logspout/healthcheck"
+	_ "github.com/gliderlabs/logspout/transports/tcp"
+	_ "github.com/gliderlabs/logspout/transports/udp"
+	_ "github.com/looplab/logspout-logstash"
+)
diff --git a/extensions/metricbeat/Dockerfile b/extensions/metricbeat/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..6d05bf55f25eb976466cce277a8a4751a3539b08
--- /dev/null
+++ b/extensions/metricbeat/Dockerfile
@@ -0,0 +1,3 @@
+ARG ELASTIC_VERSION
+
+FROM docker.elastic.co/beats/metricbeat:${ELASTIC_VERSION}
diff --git a/extensions/metricbeat/README.md b/extensions/metricbeat/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1da1eaa216d62d52af53f4ac6fd3bccd497dc3a5
--- /dev/null
+++ b/extensions/metricbeat/README.md
@@ -0,0 +1,49 @@
+# Metricbeat
+
+Metricbeat is a lightweight shipper that you can install on your servers to periodically collect metrics from the
+operating system and from services running on the server. Metricbeat takes the metrics and statistics that it collects
+and ships them to the output that you specify, such as Elasticsearch or Logstash.
+
+## Usage
+
+**This extension requires the `metricbeat_internal`, `monitoring_internal` and `beats_system` users to be created and
+initialized with a password.** In case you haven't done that during the initial startup of the stack, please refer to
+[How to re-execute the setup][setup] to run the setup container again and initialize these users.
+
+To include Metricbeat in the stack, run Docker Compose from the root of the repository with an additional command line
+argument referencing the `metricbeat-compose.yml` file:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/metricbeat/metricbeat-compose.yml up
+```
+
+## Configuring Metricbeat
+
+The Metricbeat configuration is stored in [`config/metricbeat.yml`](./config/metricbeat.yml). You can modify this file
+with the help of the [Configuration reference][metricbeat-config].
+
+Any change to the Metricbeat configuration requires a restart of the Metricbeat container:
+
+```console
+$ docker-compose -f docker-compose.yml -f extensions/metricbeat/metricbeat-compose.yml restart metricbeat
+```
+
+Please refer to the following documentation page for more details about how to configure Metricbeat inside a
+Docker container: [Run Metricbeat on Docker][metricbeat-docker].
+
+## See also
+
+[Metricbeat documentation][metricbeat-doc]
+
+## Screenshots
+
+![stack-monitoring](https://user-images.githubusercontent.com/3299086/202710574-32a3d419-86ea-4334-b6f7-62d7826df18d.png
+"Stack Monitoring")
+![host-dashboard](https://user-images.githubusercontent.com/3299086/202710594-0deccf40-3a9a-4e63-8411-2e0d9cc6ad3a.png
+"Host Overview Dashboard")
+
+[metricbeat-config]: https://www.elastic.co/guide/en/beats/metricbeat/current/metricbeat-reference-yml.html
+[metricbeat-docker]: https://www.elastic.co/guide/en/beats/metricbeat/current/running-on-docker.html
+[metricbeat-doc]: https://www.elastic.co/guide/en/beats/metricbeat/current/index.html
+
+[setup]: ../../README.md#how-to-re-execute-the-setup
diff --git a/extensions/metricbeat/config/metricbeat.yml b/extensions/metricbeat/config/metricbeat.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6dcb0925d918885d9eaa631ddb5e48996c806936
--- /dev/null
+++ b/extensions/metricbeat/config/metricbeat.yml
@@ -0,0 +1,72 @@
+## Metricbeat configuration
+## https://github.com/elastic/beats/blob/main/deploy/docker/metricbeat.docker.yml
+#
+
+name: metricbeat
+
+metricbeat.config:
+  modules:
+    path: ${path.config}/modules.d/*.yml
+    # Reload module configs as they change:
+    reload.enabled: false
+
+metricbeat.autodiscover:
+  providers:
+    - type: docker
+      hints.enabled: true
+
+metricbeat.modules:
+- module: elasticsearch
+  hosts: [ http://elasticsearch:9200 ]
+  username: monitoring_internal
+  password: ${MONITORING_INTERNAL_PASSWORD}
+  xpack.enabled: true
+  period: 10s
+  enabled: true
+- module: logstash
+  hosts: [ http://stash:9600 ]
+  xpack.enabled: true
+  period: 10s
+  enabled: true
+- module: kibana
+  hosts: [ http://kibana:5601 ]
+  username: monitoring_internal
+  password: ${MONITORING_INTERNAL_PASSWORD}
+  xpack.enabled: true
+  period: 10s
+  enabled: true
+- module: docker
+  metricsets:
+    - container
+    - cpu
+    - diskio
+    - healthcheck
+    - info
+    #- image
+    - memory
+    - network
+  hosts: [ unix:///var/run/docker.sock ]
+  period: 10s
+  enabled: true
+
+processors:
+  - add_cloud_metadata: ~
+
+monitoring:
+  enabled: true
+  elasticsearch:
+    username: beats_system
+    password: ${BEATS_SYSTEM_PASSWORD}
+
+output.elasticsearch:
+  hosts: [ http://elasticsearch:9200 ]
+  username: metricbeat_internal
+  password: ${METRICBEAT_INTERNAL_PASSWORD}
+
+## HTTP endpoint for health checking
+## https://www.elastic.co/guide/en/beats/metricbeat/current/http-endpoint.html
+#
+
+http:
+  enabled: true
+  host: 0.0.0.0
diff --git a/extensions/metricbeat/metricbeat-compose.yml b/extensions/metricbeat/metricbeat-compose.yml
new file mode 100644
index 0000000000000000000000000000000000000000..50216382a6d45f8e0df1ad03a3a3adc8286896e6
--- /dev/null
+++ b/extensions/metricbeat/metricbeat-compose.yml
@@ -0,0 +1,47 @@
+version: '3.7'
+
+services:
+  metricbeat:
+    build:
+      context: extensions/metricbeat/
+      args:
+        ELASTIC_VERSION: ${ELASTIC_VERSION}
+    # Run as 'root' instead of 'metricbeat' (uid 1000) to allow reading
+    # 'docker.sock' and the host's filesystem.
+    user: root
+    command:
+      # Log to stderr.
+      - -e
+      # Disable config file permissions checks. Allows mounting
+      # 'config/metricbeat.yml' even if it's not owned by root.
+      # see: https://www.elastic.co/guide/en/beats/libbeat/current/config-file-permissions.html
+      - --strict.perms=false
+      # Mount point of the host’s filesystem. Required to monitor the host
+      # from within a container.
+      - --system.hostfs=/hostfs
+    volumes:
+      - ./extensions/metricbeat/config/metricbeat.yml:/usr/share/metricbeat/metricbeat.yml:ro,Z
+      - type: bind
+        source: /
+        target: /hostfs
+        read_only: true
+      - type: bind
+        source: /sys/fs/cgroup
+        target: /hostfs/sys/fs/cgroup
+        read_only: true
+      - type: bind
+        source: /proc
+        target: /hostfs/proc
+        read_only: true
+      - type: bind
+        source: /var/run/docker.sock
+        target: /var/run/docker.sock
+        read_only: true
+    environment:
+      METRICBEAT_INTERNAL_PASSWORD: ${METRICBEAT_INTERNAL_PASSWORD:-}
+      MONITORING_INTERNAL_PASSWORD: ${MONITORING_INTERNAL_PASSWORD:-}
+      BEATS_SYSTEM_PASSWORD: ${BEATS_SYSTEM_PASSWORD:-}
+    networks:
+      - elk
+    depends_on:
+      - es
diff --git a/logstash/Dockerfile b/logstash/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..2d43de3ffd7d01381361a0f55b19b7682db57075
--- /dev/null
+++ b/logstash/Dockerfile
@@ -0,0 +1,15 @@
+ARG ELASTIC_VERSION
+
+# https://www.docker.elastic.co/
+FROM docker.elastic.co/logstash/logstash:${ELASTIC_VERSION}
+
+# Add your logstash plugins setup here
+# Example: RUN logstash-plugin install logstash-filter-json
+
+# USER root
+# RUN apt-get update && apt-get install -y ruby
+# RUN apt-get install -y build-essential libmysqlclient-dev ruby-dev
+# RUN gem install mysql2
+# RUN gem install nlp-pure
+# USER logstash
+# RUN logstash-plugin install nlp-pure
diff --git a/logstash/config/index-template.json b/logstash/config/index-template.json
new file mode 100644
index 0000000000000000000000000000000000000000..6a53cb2df014a64aa2c6937390724d443fa47d49
--- /dev/null
+++ b/logstash/config/index-template.json
@@ -0,0 +1,222 @@
+{
+  "index_patterns": ["mobiles", "raw_obsels"],
+  "template": {
+    "settings": {
+      "number_of_shards": 1
+    },
+    "mappings": {
+      "properties": {
+        "@id": {
+          "type": "keyword"
+        },
+        "@timestamp": {
+          "type": "date"
+        },
+        "@version": {
+          "type": "keyword"
+        },
+        "address": {
+          "type": "text"
+        },
+        "autoDelete": {
+          "type": "boolean"
+        },
+        "begin": {
+          "type": "date"
+        },
+        "city": {
+          "type": "text"
+        },
+        "coordinates": {
+          "type": "geo_point"
+        },
+        "coordinates_list": {
+          "type": "geo_shape"
+        },
+        "diploma": {
+          "type": "keyword"
+        },
+        "emoticon": {
+          "type": "keyword"
+        },
+        "post_details.emoticon": {
+          "type": "keyword"
+        },
+        "end": {
+          "type": "date"
+        },
+        "firstname": {
+          "type": "keyword"
+        },
+        "formation": {
+          "type": "keyword"
+        },
+        "gender": {
+          "type": "keyword"
+        },
+        "icons": {
+          "type": "keyword"
+        },
+        "id": {
+          "type": "keyword"
+        },
+        "post_details.id": {
+          "type": "keyword"
+        },
+        "imageId": {
+          "type": "keyword"
+        },
+        "post_details.imageId": {
+          "type": "keyword"
+        },
+        "institution": {
+          "type": "keyword"
+        },
+        "keepOn": {
+          "type": "keyword"
+        },
+        "lastname": {
+          "type": "keyword"
+        },
+        "layer": {
+          "type": "keyword"
+        },
+        "mode": {
+          "type": "keyword"
+        },
+        "nationality": {
+          "type": "keyword"
+        },
+        "placeType": {
+          "type": "keyword"
+        },
+        "post_details.placeType": {
+          "type": "keyword"
+        },
+        "position": {
+          "type": "geo_point"
+        },
+        "post_details.position": {
+          "type": "geo_point"
+        },
+        
+        "metrics.textual.tendancy": {
+          "properties": {
+            "progression_diversite_lexicale": {
+              "type": "float"
+            },
+            "evolution_complexite_syntaxique": {
+              "type": "float"
+            },
+            "dynamique_harmonie_lexico_syntaxique": {
+              "type": "float"
+            }
+          }
+        },
+        "post_details.metrics.textual.tendancy": {
+          "properties": {
+            "progression_diversite_lexicale": {
+              "type": "float"
+            },
+            "evolution_complexite_syntaxique": {
+              "type": "float"
+            },
+            "dynamique_harmonie_lexico_syntaxique": {
+              "type": "float"
+            }
+          }
+        },
+        "metrics.textual.comment":{
+          "properties":{
+            "complexite_syntaxique":{
+              "type":"float"
+            },
+            "rapport_richesse_lexicale":{
+              "type":"float"
+            }
+          }
+        },
+        "post_details.metrics.textual.comment":{
+          "properties":{
+            "complexite_syntaxique":{
+              "type":"float"
+            },
+            "rapport_richesse_lexicale":{
+              "type":"float"
+            }
+          }
+        },
+        "postId": {
+          "type": "keyword"
+        },
+        "post_details.postId": {
+          "type": "keyword"
+        },
+        "postalcode": {
+          "type": "keyword"
+        },
+        "prev_coordinates": {
+          "type": "geo_point"
+        },
+        "post_details.prev_coordinates": {
+          "type": "geo_point"
+        },
+        "shared": {
+          "type": "boolean"
+        },
+        "status": {
+          "type": "keyword"
+        },
+        "post_details.status": {
+          "type": "keyword"
+        },
+        "synchronize": {
+          "type": "keyword"
+        },
+        "tags": {
+          "type": "keyword"
+        },
+        "post_details.tags": {
+          "type": "keyword"
+        },
+        "timing": {
+          "type": "keyword"
+        },
+        "post_details.timing": {
+          "type": "keyword"
+        },
+        "tourId": {
+          "type": "keyword"
+        },
+        "post_details.tourId": {
+          "type": "keyword"
+        },
+        "tourMode": {
+          "type": "keyword"
+        },
+        "post_details.tourMode": {
+          "type": "keyword"
+        },
+        "tourName": {
+          "type": "text"
+        },
+        "type": {
+          "type": "keyword"
+        },
+        "post_details.type": {
+          "type": "keyword"
+        },
+        "userId": {
+          "type": "keyword"
+        },
+        "post_details.userId": {
+          "type": "keyword"
+        },
+        "username": {
+          "type": "keyword"
+        }
+      }
+    }
+  },
+  "priority": 1
+}
diff --git a/logstash/config/logstash.yml b/logstash/config/logstash.yml
new file mode 100644
index 0000000000000000000000000000000000000000..898e918534669808ec818f030383de56640d8b5e
--- /dev/null
+++ b/logstash/config/logstash.yml
@@ -0,0 +1,3 @@
+http.host: 0.0.0.0
+log.level: debug
+node.name: logstash
diff --git a/logstash/config/mysql-connector-j-8.2.0.jar b/logstash/config/mysql-connector-j-8.2.0.jar
new file mode 100644
index 0000000000000000000000000000000000000000..96fae380c20666d27e609e32b4684619e3132d1b
Binary files /dev/null and b/logstash/config/mysql-connector-j-8.2.0.jar differ
diff --git a/logstash/config/pipeline/logstash.conf b/logstash/config/pipeline/logstash.conf
new file mode 100644
index 0000000000000000000000000000000000000000..fad093f09060ab829ae5ba301423af22ef99f7c3
--- /dev/null
+++ b/logstash/config/pipeline/logstash.conf
@@ -0,0 +1,196 @@
+input {
+  tcp {
+    port => 50000
+  }
+
+  http_poller {
+    urls => {
+      ktbs => "${KTBS_URL}"
+    }
+    request_timeout => 60
+    schedule => { every => "1m" }
+    codec => "json"
+    user => "${KTBS_USER}"
+    password => "${KTBS_PASSWORD}"
+    ssl_verification_mode => "none"
+    type => "raw_obsels"
+  }
+}
+
+filter {
+  if [type] == "raw_obsels" {
+    split {
+      field => "obsels"
+      add_field => { "[@metadata][split]" => "true" }
+    }
+
+    if [@metadata][split] {
+      fingerprint {
+        source => ["obsels"]
+        target => "[@metadata][fingerprint]"
+        method => "SHA1"
+        key => "logstash"
+      }
+    }
+  }
+
+  ruby {
+    code => 'eval(File.read("/usr/share/logstash/ruby/processings.rb"))'
+  }
+
+  mutate {
+    convert => { "userId" => "string" }
+  }
+
+  mutate {
+    remove_field => [ "@context", "hasObselList",  "obsels", "event" ]
+  }
+}
+output {
+  # stdout { codec => rubydebug }
+
+  
+    elasticsearch {
+      hosts => ["elasticsearch:9200"]
+      user => "elastic"
+      password => "${LOGSTASH_INTERNAL_PASSWORD}"
+      index => "raw_obsels"
+      document_id => "%{id}"
+      manage_template => true
+      template => "/usr/share/logstash/config/index-template.json"
+      template_name => "mobiles_mapping"
+      template_overwrite => true
+    }
+  
+}
+
+# Uncomment the following lines for the second input configuration
+
+# input {
+#   elasticsearch {
+#     hosts => ["elasticsearch:9200"]
+#     user => "elastic"
+#     password => "${LOGSTASH_INTERNAL_PASSWORD}"
+#     index => "raw_obsels"
+#   }
+# }
+# filter {
+#   if [userId] and !("register" in [type] or "updateUser" in [type]) {
+#     http {
+#       url => "http://elastic:${LOGSTASH_INTERNAL_PASSWORD}@elasticsearch:9200/raw_obsels/_search"
+#       verb => "POST"
+#       body => '{
+#         "query": {
+#           "bool": {
+#             "must": [
+#               { "match": { "type": "m:updateUser" } },
+#               { "match": { "userId": "%{userId}" } }
+#             ]
+#           }
+#         },
+#         "size": 1
+#       }'
+#       headers => ["Content-Type", "application/json"]
+#       target_body => "user_response"
+#     }
+
+#     if [user_response][hits][total][value] > 0 {
+#       ruby {
+#         code => "
+#           user_source = event.get('[user_response][hits][hits][0][_source]')
+#           if user_source
+#             event.set('username', user_source['username'])
+#             event.set('institution', user_source['institution'])
+#           end
+#           event.remove('[user_response]')
+#         "
+#       }
+#     }
+#   }
+
+
+
+#   if [type] == "m:openPost" {
+#     http {
+#   url => "http://elastic:${LOGSTASH_INTERNAL_PASSWORD}@elasticsearch:9200/raw_obsels/_search"
+#   verb => "POST"
+#   body => '{
+#     "query": {
+#       "bool": {
+#         "must": [
+#           { "bool": {
+#               "should": [
+#                 { "match": { "type": "m:addPost" } },
+#                 { "match": { "type": "m:editPost" } }
+#               ]
+#             }
+#           },
+#           { "match": { "postId": "%{postId}" } },
+#           { "range": { "begin": { "lt": "%{begin}" } } }
+#         ]
+#       }
+#     },
+#     "sort": [
+#       { "begin": { "order": "desc" } }
+#     ],
+#     "size": 1
+#   }'
+#   headers => ["Content-Type", "application/json"]
+#   target_body => "response"
+# }
+
+#     if [response][hits][total][value] > 0 {
+#      ruby {
+#         code => "
+#           source = event.get('[response][hits][hits][0][_source]')
+#           if source
+#             event.set('post_details', source)
+#           end
+#           event.remove('[response]')
+#         "
+#       }
+  
+    
+#    if [position] {
+#       ruby {
+#         code => "
+#           position = event.get('position')
+#           if position.is_a?(Array)
+#             lon = position[0][1]
+#             lat = position[1][1]
+#             event.set('position', { 'lon' => lon, 'lat' => lat })
+#           end
+#         "
+#       }
+#     }
+
+
+#      if [coordinates] =~ /^{.*}$/ {
+#         json {
+#           source => "coordinates"
+#           target => "coordinates"
+#         }
+#       }   
+      
+#     }
+#   }
+# }
+# # filter {
+# #   grok {
+# #     match => { "message" => "%{COMBINEDAPACHELOG}" }
+# #     tag_on_failure => ["_grokparsefailure"]
+# #   }
+# # }
+
+# output {
+#   elasticsearch {
+#     hosts => ["elasticsearch:9200"]
+#     user => "elastic"
+#     password => "${LOGSTASH_INTERNAL_PASSWORD}"
+#     index => "mobiles"
+#     document_id => "%{id}"
+#     template => "/usr/share/logstash/config/index-template.json"
+#     template_name => "mobiles_mapping"
+#     template_overwrite => true
+#   }
+# }
diff --git a/logstash/ruby/processings.rb b/logstash/ruby/processings.rb
new file mode 100644
index 0000000000000000000000000000000000000000..63b8b845d1930585e98337d602c00ec706240860
--- /dev/null
+++ b/logstash/ruby/processings.rb
@@ -0,0 +1,74 @@
+require 'set'
+
+if event.get('[obsels]')
+  event.get('[obsels]').each { |k, v|
+    if k.is_a?(String)
+      new_key = k.gsub(/^m:|@/, '')
+      if new_key == 'shared' && v.is_a?(String)
+        event.set(new_key, v.to_s.downcase == 'public')
+      elsif ['dateModif', 'dateDisplay'].include?(new_key) && v.is_a?(String)
+        v = v.include?(':') ? v : v + ':00'
+        v = v.include?(' ') ? v.gsub(' ', 'T') + ':00' : v
+        event.set(new_key, v)
+      elsif ['coordinates', 'prev_coordinates'].include?(new_key)
+        json_data = v.is_a?(String) ? JSON.parse(v) : v
+        if json_data['lat'] && json_data['lon']
+          lat = json_data['lat'].to_s.gsub(',', '.').to_f
+          lon = json_data['lon'].to_s.gsub(',', '.').to_f
+          # Check if lat and lon are within the valid range
+          if lat.between?(-90, 90) && lon.between?(-180, 180)
+            event.set(new_key, {'lat' => lat, 'lon' => lon})
+          else
+            event.remove(new_key)
+          end
+        else
+          event.remove(new_key)
+        end
+      elsif new_key == 'position' && v.is_a?(String)
+        lon, lat = v.split('-').map { |x| x.split(':').last.gsub(',', '.').to_f }
+        # Check if lat and lon are within the valid range
+        if lat.between?(-90, 90) && lon.between?(-180, 180)
+          event.set(new_key, {'lat' => lat, 'lon' => lon})
+        else
+          event.remove(new_key)
+        end
+      elsif new_key == 'tourBody' && v.is_a?(String)
+        coordinates_array = JSON.parse(v)
+        if coordinates_array.length >= 4 && coordinates_array.uniq.length >= 4
+          linestring_wkt = 'LINESTRING (' + coordinates_array.each_slice(2).map { |lon, lat| lon.to_s + ' ' + lat.to_s }.join(', ') + ')'
+          event.set('coordinates_list', linestring_wkt)
+        end
+      elsif new_key == 'icons' && v.is_a?(String)
+        event.set(new_key, v.split(',').map(&:strip))
+      elsif new_key == 'tags' && v.is_a?(String)
+        event.set(new_key, v.split(' ').map(&:strip))
+      else
+        event.set(new_key, v)
+      end
+    end
+  }
+end
+
+
+
+# Convert 'userId' to string
+if event.get('userId')
+  event.set('userId', event.get('userId').to_s)
+end
+
+# Parse 'begin' and 'end' as UNIX_MS dates
+['begin', 'end'].each do |field|
+  if event.get(field)
+    event.set(field, Time.at(event.get(field).to_i / 1000.0).utc)
+  end
+end
+
+# Parse 'dateModif' as an ISO8601 date
+if event.get('dateModif')
+  event.set('dateModif', Time.parse(event.get('dateModif').to_s))
+end
+
+# Parse 'dateDisplay' as an ISO8601 date
+if event.get('dateDisplay')
+  event.set('dateDisplay', Time.parse(event.get('dateDisplay').to_s))
+end
diff --git a/rs/Dockerfile b/rs/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..280cd60bff13d87e48034a54b9f9f731a22f5cdd
--- /dev/null
+++ b/rs/Dockerfile
@@ -0,0 +1,23 @@
+# Utilisez une image de base Python adaptée à votre application
+FROM python:3.9-slim-buster
+
+# Créer le répertoire de travail
+WORKDIR /app
+
+# Copier les requirements.txt
+COPY requirements.txt requirements.txt
+
+# Installer les dépendances
+RUN pip install -r requirements.txt
+
+# Installer le modèle spaCy 'fr_core_news_sm'
+RUN python -m spacy download fr_core_news_sm
+
+# Copier le reste du code
+COPY . .
+
+# Exposer le port 8080
+EXPOSE 8080
+
+# Commande pour lancer l'application
+CMD ["python", "run.py"]
\ No newline at end of file
diff --git a/rs/config/__init__.py b/rs/config/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rs/config/settings.py b/rs/config/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..31c509624a72508323b731852f93f655b812d66b
--- /dev/null
+++ b/rs/config/settings.py
@@ -0,0 +1,21 @@
+import os
+
+class Config:
+    APP_PASSKEY=os.getenv('APP_PASSKEY', 'mobiles')
+    SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}'.format(        
+        DB_USERNAME=os.getenv('DB_USERNAME', 'mobiles'),
+        DB_PASSWORD=os.getenv('DB_PASSWORD', 'mobilespassword'),
+        DB_HOST=os.getenv('DB_HOST', 'db'),
+        DB_NAME=os.getenv('DB_NAME', 'mobiles')
+    )
+    SQLALCHEMY_TRACK_MODIFICATIONS = False
+
+    ELASTIC_URI = {
+        'host': os.getenv('ELASTIC_HOST', 'es'),
+        'port': int(os.getenv('ELASTIC_PORT', '9200')),
+        'scheme': os.getenv('ELASTIC_SCHEME', 'http'),
+        'username': os.getenv('ELASTIC_USERNAME', 'elastic'),
+        'password': os.getenv('ELASTIC_PASSWORD', 'mobilespassword')
+    }
+
+    MOBILES_API_URL = "https://anr-mobiles.fr/api/"
diff --git a/rs/modules/__init__.py b/rs/modules/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rs/modules/compute_metrics.py b/rs/modules/compute_metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..3d9d966f43d085f6799a4dcb5c7def260df08397
--- /dev/null
+++ b/rs/modules/compute_metrics.py
@@ -0,0 +1,174 @@
+from modules.db_operations import fetch_indicators, fetch_new_annotations, fetch_tours
+from modules.es_operations import ( fetch_annotation_popularity, fetch_user_ids, fetch_user_locations)
+from modules.metrics.adoption_integration_m import calculate_nc, calculate_nca, calculate_ncoa, calculate_ncr, calculate_ndl, calculate_nia, calculate_nis, calculate_nr, calculate_suf
+from modules.metrics.content_quality_m import calculate_quality_indicators
+from modules.metrics.engagement_reengagement_m import calculate_air, calculate_annotation_consultation_weight, calculate_annotation_creation_weight, calculate_aua, calculate_aun, calculate_id,  calculate_oa, calculate_ocp, calculate_os, calculate_path_consultation_weight, calculate_path_creation_weight,  calculate_pf, calculate_pla, calculate_pp, calculate_sca, calculate_scp, calculate_sea, calculate_sep, calculate_sis, calculate_tca, calculate_tcp, calculate_tel, calculate_tep, calculate_tpa, calculate_tru
+from modules.metrics.interaction_reflection_m import calculate_tac, calculate_tcc, calculate_tec, calculate_trc
+from modules.metrics.urban_discovery_m import calculate_dap, calculate_dau, calculate_dc, calculate_dmapp, calculate_dsa, calculate_prl, calculate_pze, calculate_spc
+
+
+
+def compute_adoption_metrics():    
+    user_ids = fetch_user_ids()
+
+    for user in user_ids:        
+        calculate_nia(user)
+        calculate_nis(user)
+        calculate_suf(user)
+        calculate_nca(user)
+        calculate_ncr(user)
+        calculate_ndl(user)
+        calculate_ncoa(user)
+        calculate_nc(user)
+        calculate_nr(user)
+        
+
+def compute_engagement_metrics():
+    """
+    Calcul des métriques pour la Catégorie 2 pour tous les utilisateurs.
+    """
+    user_ids = fetch_user_ids()
+    
+    # tours = fetch_tours()
+    # popularity_data = fetch_annotation_popularity()
+    # new_annotations = fetch_new_annotations()
+    
+    
+    for user_id in user_ids:
+        user_locations = fetch_user_locations(user_id)
+        indicators = fetch_indicators(user_id)
+        calculate_annotation_creation_weight(user_id)
+        calculate_annotation_consultation_weight(user_id)
+        calculate_pla(user_id, user_locations)
+        calculate_path_creation_weight(user_id)
+        calculate_path_consultation_weight(user_id)
+        calculate_id(user_id, period_days=30)
+        calculate_sea(user_id, indicators)
+        calculate_sep(user_id, indicators)
+        calculate_sca(user_id)
+        calculate_scp(user_id)
+        calculate_sis(user_id)
+
+        # calculate_annotation_creation_weight(user_id)
+        # calculate_annotation_consultation_weight(user_id)
+        # calculate_path_creation_weight(user_id)
+        # calculate_path_consultation_weight(user_id)
+        # calculate_sea(user_id)
+        # calculate_sep(user_id)
+        # calculate_pla(user_id, user_location)
+        # calculate_tel(user_id)
+        # calculate_oa(user_id)        
+        # calculate_pp(user_id, user_location)        
+        # calculate_ocp(tours, user_id)        
+        # calculate_id(user_id)        
+        # calculate_air(user_id)
+        # calculate_tpa(user_id)
+        # calculate_pf(user_id)
+        # calculate_os(user_id)
+        # calculate_aua(user_id,popularity_data)
+        # calculate_aun(user_id,new_annotations)
+        # calculate_pc_and_auc(user_id)
+        # calculate_ndg_and_aun(user_id)
+        # calculate_tru()
+        # calculate_tcp(user_id)
+        # calculate_tep(user_id)
+        # calculate_tca(user_id)
+
+
+def compute_quality_metrics():   
+    user_ids = fetch_user_ids()
+    
+    for user_id in user_ids:
+        calculate_quality_indicators(user_id)
+    
+def compute_urban_discovery_metrics():
+    user_ids = fetch_user_ids()
+    
+    for user_id in user_ids:         
+        calculate_dap(user_id)
+        calculate_prl(user_id)
+        calculate_dmapp(user_id)
+        calculate_spc(user_id)        
+        calculate_dc(user_id)
+
+        # calculate_pze(user_id)
+        # # calculate_prl(user_id)
+        
+        # calculate_dau(user_id)
+        
+        # calculate_dsa(user_id)
+
+
+def compute_interaction_reflection_metrics():
+    user_ids = fetch_user_ids()
+    for user_id in user_ids:        
+        calculate_tac(user_id)
+        calculate_tec(user_id)
+        calculate_trc(user_id)
+        calculate_tcc(user_id)
+
+
+# def calculate_metrics():
+    
+#     print("Calcul des métriques de la catégorie 1 - adoption...")
+#     compute_adoption_metrics()
+
+#     print("Calcul des métriques de la catégorie 2 - engagement...")
+#     compute_engagement_metrics()
+
+#     print("Calcul des métriques de la catégorie 3 - qualité...")
+#     compute_quality_metrics()
+
+
+#     print("Calcul des métriques de la catégorie 4 - diversité urbaine...")
+#     compute_urban_discovery_metrics()
+
+#     print("Calcul des métriques de la catégorie 5 - interaction & reflexion...")
+#     compute_interaction_reflection_metrics()
+def calculate_metrics(category=None):
+    """
+    Calcule les métriques pour une catégorie spécifique ou pour toutes les catégories si aucune n'est spécifiée.
+    
+    :param category: (str) La catégorie de métriques à calculer. Peut être 'adoption', 'engagement', 'qualité', 
+                     'diversité urbaine' ou 'interaction & reflexion'. Si None, toutes les catégories seront calculées.
+    """
+
+    if category is None:
+        print("Calcul des métriques de toutes les catégories...")
+        print("Calcul des métriques de la catégorie 1 - adoption...")
+        compute_adoption_metrics()
+
+        print("Calcul des métriques de la catégorie 2 - engagement...")
+        compute_engagement_metrics()
+
+        print("Calcul des métriques de la catégorie 3 - qualité...")
+        compute_quality_metrics()
+
+        print("Calcul des métriques de la catégorie 4 - diversité urbaine...")
+        compute_urban_discovery_metrics()
+
+        print("Calcul des métriques de la catégorie 5 - interaction & reflexion...")
+        compute_interaction_reflection_metrics()
+    
+    elif category == "adoption":
+        print("Calcul des métriques de la catégorie 1 - adoption...")
+        compute_adoption_metrics()
+        
+    elif category == "engagement":
+        print("Calcul des métriques de la catégorie 2 - engagement...")
+        compute_engagement_metrics()
+        
+    elif category == "quality":
+        print("Calcul des métriques de la catégorie 3 - qualité...")
+        compute_quality_metrics()
+        
+    elif category == "urbain":
+        print("Calcul des métriques de la catégorie 4 - diversité urbaine...")
+        compute_urban_discovery_metrics()
+        
+    elif category == "reflexion":
+        print("Calcul des métriques de la catégorie 5 - interaction & reflexion...")
+        compute_interaction_reflection_metrics()
+        
+    else:
+        print(f"Catégorie inconnue : {category}. Veuillez spécifier une catégorie valide.")
diff --git a/rs/modules/db_operations.py b/rs/modules/db_operations.py
new file mode 100644
index 0000000000000000000000000000000000000000..049176f3c33febb9f27a0929bd323e8e1d4491f4
--- /dev/null
+++ b/rs/modules/db_operations.py
@@ -0,0 +1,1229 @@
+import re
+from dateutil import parser
+import pandas as pd
+import requests
+from sqlalchemy import Text, create_engine, MetaData, Table, Column, Integer, String, Float, JSON, DateTime, inspect, text
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.engine import reflection
+from flask import current_app
+from datetime import datetime, timedelta
+from geopy.distance import geodesic
+
+import json
+
+# Authentication function
+def get_auth_token(email, password):
+    login_url = current_app.config['MOBILES_API_URL'] +"login_check"
+    login_data = {"mail": email, "password": password}
+    try:
+        response = requests.post(login_url, json=login_data)
+        response.raise_for_status()
+        return response.json().get("token")
+    except requests.RequestException as err:
+        print(f"Erreur lors de la connexion : {err}")
+        return None
+
+# Function to fetch users from remote API
+def fetch_data_from_api(api_url, auth_token, limit=50):
+    headers = {"Authorization": f"Bearer {auth_token}"}
+    data = []
+    fetched_ids = set()
+    page = 1
+
+    while True:
+        try:
+            params = {"page": page, "limit": limit}
+            response = requests.get(api_url, headers=headers, params=params)
+            response.raise_for_status()
+            response_data = response.json().get("hydra:member", [])
+
+            if not response_data:
+                break
+
+            new_data_count = 0
+            for item in response_data:
+                item_id = item.get("id")
+                if item_id in fetched_ids:
+                    break
+                fetched_ids.add(item_id)
+                data.append(item)
+                new_data_count += 1
+
+            if new_data_count == 0 or len(response_data) < limit:
+                break
+
+            page += 1
+        except requests.RequestException as err:
+            print(f"Erreur: {err}")
+            return []
+    
+    print(f"Total items fetched: {len(data)}")
+    return data
+
+def get_distant_users():
+    auth_token = get_auth_token('madjid.sadallah@liris.cnrs.fr', '!MSada2024')
+    if auth_token:
+        api_url = current_app.config['MOBILES_API_URL'] +"users"
+        return fetch_data_from_api(api_url, auth_token)
+    return []
+
+def get_distant_annotations():
+    auth_token = get_auth_token('madjid.sadallah@liris.cnrs.fr', '!MSada2024')
+    if auth_token:
+        api_url = current_app.config['MOBILES_API_URL'] +"annotations"
+        annotations = fetch_data_from_api(api_url, auth_token)
+
+        # Extraire l'ID de l'utilisateur des annotations
+        for annotation in annotations:
+            user_url = annotation.get('user')  # Assurez-vous que 'user' est la clé appropriée
+            if user_url:
+                # Utiliser une expression régulière pour extraire l'ID
+                match = re.search(r'/api/users/(\d+)', user_url)
+                if match:
+                    user_id = match.group(1)
+                    annotation['user'] = user_id  # Ajouter l'ID de l'utilisateur au dictionnaire
+            
+            tour_url = annotation.get('tour')  # Assurez-vous que 'user' est la clé appropriée
+            if tour_url:
+                # Utiliser une expression régulière pour extraire l'ID
+                match = re.search(r'/api/tours/(\d+)', tour_url)
+                if match:
+                    tour_id = match.group(1)
+                    annotation['tour'] = tour_id  # Ajouter l'ID de l'utilisateur au dictionnaire
+            
+            messages_urls = annotation.get('messages', [])
+            if messages_urls:
+                annotation['messages'] = [re.search(r'/api/messages/(\d+)', url).group(1) for url in messages_urls if re.search(r'/api/messages/(\d+)', url)]
+        return annotations
+    return []
+
+def get_distant_tours():
+    auth_token = get_auth_token('madjid.sadallah@liris.cnrs.fr', '!MSada2024')
+    if auth_token:        
+        api_url = current_app.config['MOBILES_API_URL'] +"tours"
+        tours = fetch_data_from_api(api_url, auth_token)
+        for tour in tours:
+            user_url = tour.get('user')  # Assurez-vous que 'user' est la clé appropriée
+            if user_url:
+                # Utiliser une expression régulière pour extraire l'ID
+                match = re.search(r'/api/users/(\d+)', user_url)
+                if match:
+                    user_id = match.group(1)
+                    tour['user'] = user_id  # Ajouter l'ID de l'utilisateur au dictionnaire         
+            annotation_urls = tour.get('annotations', [])
+            if annotation_urls:
+                tour['annotations'] = [re.search(r'/api/annotations/(\d+)', url).group(1) for url in annotation_urls if re.search(r'/api/annotations/(\d+)',url)]
+
+            linked_annotations_urls = tour.get('linked_annotations', [])
+            if linked_annotations_urls:
+                tour['linked_annotations'] = [re.search(r'/api/annotations/(\d+)', url).group(1) for url in linked_annotations_urls if re.search(r'/api/annotations/(\d+)',url)]
+
+            messages_urls = tour.get('messages', [])
+            if messages_urls:
+                tour['messages'] = [re.search(r'/api/messages/(\d+)', url).group(1) for url in messages_urls if re.search(r'/api/messages/(\d+)',url)]
+        return tours
+    return []
+
+def get_distant_messages():
+    auth_token = get_auth_token('madjid.sadallah@liris.cnrs.fr', '!MSada2024')
+    if auth_token:
+        api_url =  current_app.config['MOBILES_API_URL'] +"messages"
+        messages = fetch_data_from_api(api_url, auth_token)
+        for msg in messages:
+            user_url = msg.get('user')  # Assurez-vous que 'user' est la clé appropriée
+            if user_url:
+            # Utiliser une expression régulière pour extraire l'ID
+                match = re.search(r'/api/users/(\d+)', user_url)
+                if match:
+                    user_id = match.group(1)
+                    msg['user'] = user_id  # Ajouter l'ID de l'utilisateur au dictionnaire
+            annotation_url = msg.get('annotation')  # Assurez-vous que 'user' est la clé appropriée
+            if annotation_url:
+            # Utiliser une expression régulière pour extraire l'ID
+                match = re.search(r'/api/annotations/(\d+)', annotation_url)
+                if match:
+                    annotation_id = match.group(1)
+                    msg['annotation'] = annotation_id  # Ajouter l'ID de l'utilisateur au dictionnaire
+        return messages
+    return []
+
+# Database connection function
+def connect_to_local_database():
+    db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
+    try:
+        engine = create_engine(db_url, echo=False)
+        Session = sessionmaker(bind=engine)
+        return Session(), engine
+    except Exception as e:
+        print(f"Error connecting to database: {e}")
+        return None, None
+
+# Utility to create or update a table
+def create_or_update_table(engine, table_name, data, string_length=255, ignore_columns=None):
+    """
+    Crée ou met à jour une table dans la base de données en tolérant les valeurs NULL et en ignorant certaines colonnes.
+
+    :param engine: L'objet Engine SQLAlchemy.
+    :param table_name: Le nom de la table.
+    :param data: Les données pour initialiser la table.
+    :param string_length: Longueur maximale pour les colonnes de type String.
+    :param ignore_columns: Liste des colonnes à ignorer lors de la création/mise à jour.
+    """
+    if ignore_columns is None:
+        ignore_columns = []
+
+    try:
+        # Détermine les types de colonnes en fonction des données fournies
+        column_types = {
+            key: (Text if key == 'devices' or key == 'comment' or key == 'body' else (String(length=string_length) if isinstance(value, str) else 
+                 Float if isinstance(value, (int, float)) else 
+                 JSON))
+            for item in data for key, value in item.items()
+            if key not in ignore_columns  # Ignore les colonnes spécifiées
+        }
+        column_types['id'] = Integer  # Ajoute l'ID en tant que colonne Integer
+
+        # Définition des colonnes pour la table avec tolérance pour les valeurs NULL
+        columns = [Column(name, column_type, nullable=True) for name, column_type in column_types.items()]
+        metadata = MetaData()
+        table = Table(table_name, metadata, *columns, extend_existing=True)
+
+        # Vérifie si la table existe déjà dans la base de données
+        inspector = inspect(engine)
+        table_exists = inspector.has_table(table_name)
+
+        # Si la table n'existe pas, crée-la
+        if not table_exists:
+            metadata.create_all(engine)  # Crée la table
+        else:
+            # Obtenez les colonnes existantes et comparez-les avec les nouvelles
+            existing_columns = set(col['name'] for col in inspector.get_columns(table_name))
+            new_columns = set(column_types.keys()) - existing_columns
+            with engine.connect() as conn:
+                for new_column in new_columns:
+                    # Créer une instance du type de colonne et compiler pour le dialecte SQL spécifique
+                    column_type_instance = column_types[new_column]
+                    if new_column not in ignore_columns:  # Ignore les colonnes spécifiées
+                        alter_stmt = f"ALTER TABLE {table_name} ADD COLUMN {new_column} {column_type_instance}"
+                        conn.execute(text(alter_stmt))
+
+        # Utiliser une session transactionnelle pour insérer les données
+        Session = sessionmaker(bind=engine)
+        session = Session()
+
+        # Suppression des anciennes données
+        try:
+            session.execute(table.delete())  # Supprime toutes les lignes de la table
+            session.commit()  # Valide la suppression
+            print(f"Anciennes données supprimées de la table '{table_name}'.")
+        except SQLAlchemyError as e:
+            session.rollback()
+            print(f"Erreur lors de la suppression des anciennes données dans la table {table_name}: {e}")
+        
+        # Préparez les données avec des valeurs NULL pour les colonnes manquantes
+        for item in data:
+            for column in table.columns.keys():
+                if column not in item:
+                    item[column] = None  # Affectez None si la colonne est manquante
+
+        try:
+            session.execute(table.insert(), data)  # Insère les nouvelles données
+            session.commit()  # Valide la transaction
+            print(f"Table '{table_name}' créée ou mise à jour avec succès et données insérées.")
+
+        except SQLAlchemyError as e:
+            session.rollback()  # Annule la transaction en cas d'erreur
+            print(f"Une erreur s'est produite lors de l'insertion des données dans la table {table_name} : {e}")
+        finally:
+            session.close()
+
+    except SQLAlchemyError as e:
+        print(f"Une erreur s'est produite lors de la création ou de la mise à jour de la table {table_name} : {e}")
+
+
+        
+def old_create_or_update_table(engine, table_name, data):
+    try:
+        column_types = {key: String(length=255) if isinstance(value, str) else Float if isinstance(value, (int, float)) else JSON
+                        for item in data for key, value in item.items()}
+        column_types['id'] = Integer
+
+        columns = [Column(name, column_types[name]) for name in column_types.keys()]
+        table = Table(table_name, MetaData(), *columns, extend_existing=True)
+
+        inspector = reflection.Inspector.from_engine(engine)
+        table_exists = table_name in inspector.get_table_names()
+
+        if not table_exists:
+            table.create(engine, checkfirst=True)
+        else:
+            existing_columns = set(col['name'] for col in inspector.get_columns(table_name))
+            new_columns = set(column_types.keys()) - existing_columns
+            with engine.connect() as conn:
+                for new_column in new_columns:
+                    print(new_column)
+                    if new_column != '@type':
+                        alter_stmt = f"ALTER TABLE {table_name} ADD COLUMN {new_column} {column_types[new_column].compile(dialect=engine.dialect)}"
+                        conn.execute(text(alter_stmt))
+
+        with engine.connect() as conn:
+            conn.execute(table.delete())
+            conn.execute(table.insert().prefix_with("IGNORE"), data)
+
+        print(f"Table '{table_name}' créée ou mise à jour avec succès.")
+    except SQLAlchemyError as e:
+        print(f"Une erreur s'est produite lors de la création ou de la mise à jour de la table {table_name} : {e}")
+
+def save_users_to_local_database(users):
+    session, engine = connect_to_local_database()
+    if session and engine:
+        create_or_update_table(engine, 'users', users, ignore_columns=['@type','@id','testgroup'])
+        session.close()
+
+def save_annotations_to_local_database(annotations):
+    session, engine = connect_to_local_database()
+    if session and engine:
+        create_or_update_table(engine, 'annotations', annotations, ignore_columns=['@type','@id','testgroup'])
+        session.close()
+
+def save_tours_to_local_database(annotations):
+    session, engine = connect_to_local_database()
+    if session and engine:
+        create_or_update_table(engine, 'tours', annotations, ignore_columns=['@type','@id'])
+        session.close()
+
+def save_messages_to_local_database(messages):
+    session, engine = connect_to_local_database()
+    if session and engine:
+        create_or_update_table(engine, 'messages', messages, ignore_columns=['@type','@id'])
+        session.close()
+
+def format_date(date_string):
+    try:
+        parsed_date = parser.parse(date_string)
+        return parsed_date.strftime('%Y-%m-%d')
+    except (ValueError, TypeError) as e:
+        print(f"Erreur lors du formatage de la date: {e}")
+        return None
+
+# Insert functions
+def insert_data(table_name, data):
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        print('There is some internal error')
+        return
+    
+    table = Table(table_name, MetaData(), autoload_with=engine)
+    session.execute(table.insert().values(**data))
+    session.commit()
+    session.close()
+
+def insert_indicator(user_id, category, strategy, indicator_type, value):
+    # print("User: ",user_id," - category :", category, " - strategy: ", strategy, " - type: ", indicator_type, " - value: ", value)
+    # return
+
+    # Convertir les valeurs en JSON si nécessaire
+    if isinstance(value, list):
+        value = json.dumps(value)
+    data = {
+        'user_id': user_id,
+        'category': category,
+        'strategy': strategy,
+        'type': indicator_type,
+        'value': value,
+        'date': datetime.utcnow()
+    }
+    insert_data('indicators', data)
+
+def insert_recommendation(user_id, category, strategy, title, recommendation, suggestion, suggestion_type):
+    # Écarter les anciennes recommandations de la même stratégie pour cet utilisateur
+    discard_old_recommendations(user_id, strategy)
+
+    # Préparer les données de la nouvelle recommandation
+    data = {
+        'user_id': user_id,
+        'category': category,
+        'strategy': strategy,
+        'title': title,
+        'recommendation': recommendation,
+        'suggestion': suggestion,
+        'suggestion_type': suggestion_type,
+        'created_at': datetime.utcnow(),
+        'status': 'Created'  # Nouvel ajout : définir le statut à 'Created'
+    }
+    
+    # Insérer la nouvelle recommandation
+    insert_data('recommendations', data)
+
+
+def discard_old_recommendations(user_id, strategy):
+    """
+    Marque toutes les recommandations existantes d'un utilisateur avec la même stratégie comme 'discarded'.
+
+    :param user_id: L'ID de l'utilisateur pour lequel les recommandations doivent être écartées.
+    :param strategy: La stratégie des recommandations à écarter.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return False
+
+    try:
+        # Récupérer la table 'recommendations'
+        metadata = MetaData()
+        recommendations_table = Table('recommendations', metadata, autoload_with=engine)
+
+        # Récupérer les recommandations à écarter
+        select_query = (
+            recommendations_table.select()
+            .where((recommendations_table.c.user_id == user_id) & 
+                   (recommendations_table.c.strategy == strategy) & 
+                   (recommendations_table.c.status != 'discarded'))  # Éviter de sélectionner celles déjà 'discarded'
+        )
+        
+        # Exécuter la requête de sélection
+        result = session.execute(select_query)
+        recommendations_to_discard = result.fetchall()
+
+        # Écarter chaque recommandation trouvée
+        for recommendation in recommendations_to_discard:
+            update_recommendation_status(recommendation.id, 'discarded')
+
+        session.close()
+
+        print(f"Les recommandations avec la stratégie '{strategy}' pour l'utilisateur {user_id} ont été marquées comme 'discarded'.")
+        return True
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de l'écartement des recommandations: {e}")
+        session.rollback()
+        session.close()
+        return False
+
+
+
+def fetch_users_with_pending_recommendations():
+    """
+    Récupère tous les utilisateurs ayant des recommandations à analyser, 
+    c'est-à-dire des recommandations avec le statut 'Created' ou 'Discarded'.
+    
+    :return: DataFrame contenant les utilisateurs avec des recommandations à analyser
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        # Charger la table des recommandations
+        metadata = MetaData()
+        recommendations_table = Table('recommendations', metadata, autoload_with=engine)
+
+        # Créer la requête pour récupérer les utilisateurs avec des recommandations 'Created' ou 'Discarded'
+        query = session.query(recommendations_table.c.user_id).filter(
+            recommendations_table.c.status.in_(["Created", "Discarded"])
+        ).distinct()  # Utiliser distinct() pour éviter les doublons d'utilisateurs
+
+        # Exécuter la requête et récupérer les données dans un DataFrame
+        result = session.execute(query)
+        data = pd.DataFrame(result.fetchall(), columns=["user_id"])
+
+        session.close()
+        return data
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des utilisateurs avec des recommandations à analyser: {e}")
+        session.close()
+        return []
+
+def insert_impact(user_id, category, strategy, analysis_type, result):
+    data = {
+        'user_id': user_id,
+        'category': category,
+        'strategy': strategy,
+        'analysis_type': analysis_type,
+        'result': result,
+        'date': datetime.utcnow()
+    }
+    insert_data('impacts', data)
+
+# Fetch data functions
+# def fetch_data(table_name):
+#     session, engine = connect_to_local_database()
+#     if not session or not engine:
+#         return []
+
+#     table = Table(table_name, MetaData(), autoload_with=engine)
+#     query = session.query(table)
+#     data = pd.read_sql(query.statement, engine)
+#     session.close()
+#     return data
+
+def fetch_data(table_name, exclude_fields=None, include_fields=None, field_filters_to_include=None, field_filters_to_exclude=None):
+    """
+    Récupère des données à partir d'une table spécifiée en argument.
+    Args:
+        table_name (str): Nom de la table.
+        exclude_fields (list, optional): Liste des champs à exclure. Par défaut, None.
+        include_fields (list, optional): Liste des champs à inclure. Par défaut, None.
+        field_filters_to_include (dict, optional): Filtres d'inclusion pour les champs spécifiques. Par défaut, None.
+            Exemple : {'champ_X': ['VAL1', 'VAL2'], 'champ_Y': ['AUTRE_VAL']}
+        field_filters_to_exclude (dict, optional): Filtres d'exclusion pour les champs spécifiques. Par défaut, None.
+            Exemple : {'champ_A': ['EXCL_VAL1', 'EXCL_VAL2']}
+    Returns:
+        pandas.DataFrame: Les données récupérées.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return pd.DataFrame()
+
+    table = Table(table_name, MetaData(), autoload_with=engine)
+    query = session.query(table)
+
+    if exclude_fields:
+        for field in exclude_fields:
+            query = query.filter(getattr(table.c, field) != None)
+
+    if include_fields:
+        for field in include_fields:
+            query = query.filter(getattr(table.c, field) != None)
+
+    if field_filters_to_include:
+        for field, values in field_filters_to_include.items():
+            query = query.filter(getattr(table.c, field).in_(values))
+
+    if field_filters_to_exclude:
+        for field, values in field_filters_to_exclude.items():
+            query = query.filter(~getattr(table.c, field).in_(values))
+
+    data = pd.read_sql(query.statement, engine)
+    session.close()
+    return data
+
+def fetch_annotations_by_user(user_id, since_date=None):
+    
+    """
+    Récupère les annotations faites par un utilisateur, optionnellement filtrées depuis une certaine date.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les annotations sont considérées (facultatif).
+    :return: DataFrame contenant les annotations.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return pd.DataFrame()
+
+    annotations_table = Table('annotations', MetaData(), autoload_with=engine)
+    
+    
+    # Construire la requête
+    query = session.query(annotations_table).filter_by(user=user_id)
+    
+    
+    if since_date:
+        query = query.filter(annotations_table.c.date >= since_date)
+   
+    # Exécuter la requête et charger les résultats dans un DataFrame
+    data = pd.read_sql(query.statement, engine)
+    
+    session.close()
+    
+    return data
+
+def fetch_annotations_by_id(ids):
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return pd.DataFrame()
+
+    annotations_table = Table('annotations', MetaData(), autoload_with=engine)
+
+    # Vérifier si ids est un seul ID et le convertir en liste
+    if isinstance(ids, int):
+        ids = [ids]
+    
+    
+    # Construire la requête
+    query = session.query(annotations_table).filter(annotations_table.c.id.in_(ids))
+    
+    # Exécuter la requête et charger les résultats dans un DataFrame
+    data = pd.read_sql(query.statement, engine)
+    
+    session.close()
+    
+    return data
+
+
+
+
+def fetch_new_annotations(since_date=None):
+    """
+    Récupère les annotations récentes à partir d'une date donnée ou des 7 derniers jours si aucune date n'est fournie.
+    
+    :param since_date: Date depuis laquelle les annotations sont considérées comme nouvelles.
+    :return: DataFrame contenant les annotations récentes.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return pd.DataFrame()
+
+    annotations_table = Table('annotations', MetaData(), autoload_with=engine)
+    
+    # Si since_date est None, prendre les 7 derniers jours
+    if since_date is None:
+        since_date = datetime.now() - timedelta(days=7)
+    
+    # Construire la requête pour récupérer les annotations récentes
+    query = session.query(annotations_table).filter(annotations_table.c.date >= since_date)
+    
+    # Exécuter la requête et charger les résultats dans un DataFrame
+    data = pd.read_sql(query.statement, engine)
+    
+    session.close()
+    engine.dispose()
+    
+    return data
+
+
+
+
+def get_user_registration_date(user_id):
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return None
+
+    try:
+        user_table = Table('users', MetaData(), autoload_with=engine)
+        query = session.query(user_table.c.date).filter_by(id=user_id).first()
+        session.close()
+
+
+        if query and query[0]:
+            return format_date(query[0])
+        else:
+            print(f"Aucune date d'enreistrement trouvée pour l'utilisateur {user_id}")
+            return None
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération de la date d'inscription de l'utilisateur {user_id}: {e}")
+        session.close()
+        return None
+
+def fetch_users():
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        user_table = Table('users', MetaData(), autoload_with=engine)
+        query = session.query(user_table)
+        data = pd.read_sql(query.statement, engine)
+        session.close()
+        return data
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des utilisateurs: {e}")
+        session.close()
+        return []
+
+def fetch_annotations(exclude_fields=None, include_fields=None, field_filters_to_include=None, field_filters_to_exclude=None):
+    """
+    Récupère des annotations en utilisant la fonction fetch_data avec des filtres.
+    Args:
+        exclude_fields (list, optional): Liste des champs à exclure. Par défaut, None.
+        include_fields (list, optional): Liste des champs à inclure. Par défaut, None.
+        field_filters_to_include (dict, optional): Filtres d'inclusion pour les champs spécifiques. Par défaut, None.
+            Exemple : {'champ_X': ['VAL1', 'VAL2'], 'champ_Y': ['AUTRE_VAL']}
+        field_filters_to_exclude (dict, optional): Filtres d'exclusion pour les champs spécifiques. Par défaut, None.
+            Exemple : {'champ_A': ['EXCL_VAL1', 'EXCL_VAL2']}
+    Returns:
+        pandas.DataFrame: Les données récupérées.
+    """
+    try:
+        data = fetch_data('annotations',
+                          exclude_fields=exclude_fields,
+                          include_fields=include_fields,
+                          field_filters_to_include=field_filters_to_include,
+                          field_filters_to_exclude=field_filters_to_exclude)
+        return data
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des annotations : {e}")
+        return []
+
+
+def fetch_tours(exclude_fields=None, include_fields=None, 
+                field_filters_to_include=None, field_filters_to_exclude=None):
+    """
+    Récupère des tours en utilisant la fonction fetch_data avec des filtres.
+    
+    Args:
+        exclude_fields (list, optional): Liste des champs à exclure. Par défaut, None.
+        include_fields (list, optional): Liste des champs à inclure. Par défaut, None.
+        field_filters_to_include (dict, optional): Filtres d'inclusion pour les champs spécifiques. Par défaut, None.
+        field_filters_to_exclude (dict, optional): Filtres d'exclusion pour les champs spécifiques. Par défaut, None.
+        
+    Returns:
+        pandas.DataFrame: Les données récupérées.
+    """
+    try:
+        data = fetch_data('tours',
+                          exclude_fields=exclude_fields,
+                          include_fields=include_fields,
+                          field_filters_to_include=field_filters_to_include,
+                          field_filters_to_exclude=field_filters_to_exclude)
+        return data
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des tours : {e}")
+        return []
+
+def fetch_comments():
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        comment_table = Table('comments', MetaData(), autoload_with=engine)
+        query = session.query(comment_table)
+        data = pd.read_sql(query.statement, engine)
+        session.close()
+        return data
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des comments: {e}")
+        session.close()
+        return []
+    
+def fetch_notifications():
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        notifications_table = Table('notifications', MetaData(), autoload_with=engine)
+        query = session.query(notifications_table)
+        data = pd.read_sql(query.statement, engine)
+        session.close()
+        return data
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des notifications: {e}")
+        session.close()
+        return []
+
+def fetch_recommendations(user_id=None):
+    """
+    Récupère les recommandations de la base de données. Si user_id est spécifié,
+    la fonction filtre les recommandations pour cet utilisateur.
+    
+    :param user_id: ID de l'utilisateur (optionnel)
+    :return: DataFrame contenant les recommandations
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        notifications_table = Table('recommendations', MetaData(), autoload_with=engine)
+        query = session.query(notifications_table)
+
+        # Si un user_id est fourni, on filtre par cet utilisateur
+        if user_id is not None:
+            query = query.filter(notifications_table.c.user_id == user_id)
+
+        # Exécuter la requête et récupérer les données dans un DataFrame
+        data = pd.read_sql(query.statement, engine)
+        
+        session.close()
+        return data
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des recommandations: {e}")
+        session.close()
+        return []
+
+
+def update_recommendation_status(recommendation_id, new_status):
+    """
+    Met à jour le statut d'une recommandation spécifique en fonction de l'ID de la recommandation.
+    
+    :param recommendation_id: ID de la recommandation à mettre à jour.
+    :param new_status: Le nouveau statut à attribuer à la recommandation.
+    :return: True si la mise à jour a réussi, False sinon.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return False
+
+    try:
+        # Récupérer la table 'recommendations'
+        metadata = MetaData()
+        recommendations_table = Table('recommendations', metadata, autoload_with=engine)
+
+        # Mettre à jour le statut et la date de mise à jour du statut
+        update_query = (
+            recommendations_table.update()
+            .where(recommendations_table.c.id == recommendation_id)
+            .values(status=new_status, status_updated_at=datetime.utcnow())
+        )
+
+        # Exécuter la requête de mise à jour
+        session.execute(update_query)
+        session.commit()
+        session.close()
+
+        print(f"Le statut de la recommandation {recommendation_id} a été mis à jour en '{new_status}'.")
+        return True
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la mise à jour du statut de la recommandation: {e}")
+        session.rollback()
+        session.close()
+        return False
+  
+
+
+def fetch_user_annotations(user_id, since_date=None):
+    """
+    Récupère les annotations faites par un utilisateur depuis une date donnée ou toutes les annotations si aucune date n'est fournie.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date depuis laquelle les annotations sont considérées (doit être un objet datetime ou None).
+    :return: Liste des annotations avec leurs dates de création et de modification.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        annotations_table = Table('annotations', MetaData(), autoload_with=engine)
+
+        # Construire la requête en fonction de la présence ou non de since_date
+        if since_date is None:
+            # Prendre toutes les annotations si aucune date n'est fournie
+            query = session.query(annotations_table).filter(annotations_table.c.user == user_id)
+        else:
+            # Vérifiez que since_date est un objet datetime valide
+            if isinstance(since_date, datetime):
+                query = (
+                    session.query(annotations_table)
+                    .filter(annotations_table.c.user == user_id)
+                    .filter(
+                        (annotations_table.c.date >= since_date) | 
+                        (annotations_table.c.dateModif >= since_date)
+                    )
+                )
+            else:
+                # Si since_date n'est pas un datetime, lancer une exception
+                raise ValueError("since_date doit être un objet datetime valide. Voici son contenu: ", since_date)
+
+        # Exécuter la requête et récupérer les résultats sous forme de DataFrame
+        result = pd.read_sql(query.statement, engine)
+
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des annotations: {e}")
+        result = []
+    except ValueError as ve:
+        print(f"Erreur de validation: {ve}")
+        result = []
+    finally:
+        session.close()
+        engine.dispose()
+
+    return result
+
+
+def fetch_user_tours(user_id, since_date=None):
+    
+    """
+    Récupère les tours faites par un utilisateur, optionnellement filtrées depuis une certaine date.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les annotations sont considérées (facultatif).
+    :return: DataFrame contenant les annotations.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return pd.DataFrame()
+
+    tour_table = Table('tours', MetaData(), autoload_with=engine)
+    
+    
+    # Construire la requête
+    query = session.query(tour_table).filter_by(user=user_id)
+    
+    
+    if since_date:
+        query = query.filter(tour_table.c.date >= since_date)
+   
+    # Exécuter la requête et charger les résultats dans un DataFrame
+    data = pd.read_sql(query.statement, engine)
+    
+    session.close()
+    
+    return data
+
+def fetch_user_comments(user_id, since_date):
+    """
+    Récupère les commentaires faits par un utilisateur depuis une date donnée.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date depuis laquelle les commentaires sont considérés.
+    :return: Liste des commentaires avec leurs dates de création.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        comments_table = Table('messages', MetaData(), autoload_with=engine)
+        query = (
+            session.query(comments_table.c.id, comments_table.c.annotation, comments_table.c.date)
+            .filter(comments_table.c.user == '/api/users/'+user_id)
+            .filter(comments_table.c.date >= since_date)
+        )
+        result = pd.read_sql(query.statement, engine)
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des commentaires: {e}")
+        result = []
+    finally:
+        session.close()
+        engine.dispose()
+
+    return result
+
+def fetch_annotation_comments(annotation_id):
+    """
+    Récupère les commentaires faits sur une annotation.
+    
+    :param annotation_id: ID de l'annotation.
+    :return: Liste des commentaires (IDs)
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        comments_table = Table('messages', MetaData(), autoload_with=engine)
+        query = (
+            session.query(comments_table.c.id)
+            .filter(comments_table.c.annotation == annotation_id)
+        )
+        result = pd.read_sql(query.statement, engine)
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des commentaires: {e}")
+        result = []
+    finally:
+        session.close()
+        engine.dispose()
+
+    return result
+
+def fetch_annotations_around_location(location, radius_km=5):
+    """
+    Récupère les annotations autour d'une localisation donnée dans la base de données.
+    
+    :param location: Dictionnaire contenant les coordonnées avec les clés 'lat' et 'lon'.
+    :param radius_km: Rayon autour de la localisation pour la recherche des annotations en kilomètres.
+    :return: Liste des annotations trouvées.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        annotations_table = Table('annotations', MetaData(), autoload_with=engine)
+        query = (
+            session.query(annotations_table.c.id, annotations_table.c.coords)
+            .filter(annotations_table.c.coords.isnot(None))
+        )
+        result = pd.read_sql(query.statement, engine)
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des annotations: {e}")
+        result = pd.DataFrame(columns=['id', 'coords'])
+    finally:
+        session.close()
+        engine.dispose()
+
+    nearby_annotations = []
+    for _, row in result.iterrows():
+        id = row['id']
+        coords_dict = json.loads(row['coords'])
+        lat = coords_dict.get('lat')
+        lon = coords_dict.get('lon')
+        if lat is not None and lon is not None:
+            distance = geodesic((location['lat'], location['lon']), (lat, lon)).km
+            if distance <= radius_km:
+                nearby_annotations.append((id, lat, lon))
+
+    return nearby_annotations
+
+def fetch_annotations_with_coordinates():
+    """
+    Récupère les annotations avec leurs coordonnées depuis la base de données.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        annotations_table = Table('annotations', MetaData(), autoload_with=engine)
+        query = (
+            session.query(annotations_table.c.id, annotations_table.c.coords)
+            .filter(annotations_table.c.coords.isnot(None))
+        )
+        result = pd.read_sql(query.statement, engine)
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des annotations avec coordonnées: {e}")
+        result = pd.DataFrame(columns=['id', 'coords'])
+    finally:
+        session.close()
+        engine.dispose()
+
+    # Extraire latitude et longitude de la colonne coords
+    annotations_with_coords = []
+    for _, row in result.iterrows():
+        id = row['id']
+        coords_dict = json.loads(row['coords'])  # Parse JSON string to dictionary
+        lat = coords_dict.get('lat')
+        lon = coords_dict.get('lon')
+        if lat is not None and lon is not None:
+            annotations_with_coords.append((id, lat, lon))
+
+    return annotations_with_coords
+
+
+
+
+def fetch_place_types_from_annotations():
+    """
+    Récupère les différents types de lieux à partir des annotations.
+
+    :return: Liste des types de lieux.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        annotations_table = Table('annotations', MetaData(), autoload_with=engine)
+        query = (
+            session.query(annotations_table.c.placeType)
+            .distinct()
+        )
+        result = query.all()
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des types de lieux : {e}")
+        result = []
+    finally:
+        session.close()
+        engine.dispose()
+
+    return [type_[0] for type_ in result]
+
+def fetch_recent_annotations(since_date):
+    """
+    Récupère les annotations ajoutées récemment depuis une date donnée.
+
+    :param since_date: Date à partir de laquelle les annotations sont considérées comme récentes.
+    :return: Liste des annotations ajoutées récemment avec leurs coordonnées.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        annotations_table = Table('annotations', MetaData(), autoload_with=engine)
+        query = (
+            session.query(annotations_table.c.id, annotations_table.c.coords, annotations_table.c.date)
+            .filter(annotations_table.c.date >= since_date)
+        )
+        result = query.all()
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des annotations récentes : {e}")
+        result = []
+    finally:
+        session.close()
+        engine.dispose()
+
+    recent_annotations_with_coords = []
+    for annotation in result:
+        id, coords, date = annotation
+        try:
+            coords_dict = json.loads(coords)
+            lat = coords_dict.get('lat')
+            lon = coords_dict.get('lon')
+            if lat is not None and lon is not None:
+                recent_annotations_with_coords.append((id, lat, lon, date))
+        except json.JSONDecodeError as e:
+            print(f"Erreur lors du décodage des coordonnées : {e}")
+
+    return recent_annotations_with_coords
+
+def fetch_indicators(user_id=None, indicator_type=None, category=None, since_date=None, past_date=None):
+    """
+    Récupère les indicateurs d'utilisateur en fonction des filtres optionnels fournis.
+    
+    :param user_id: (facultatif) ID de l'utilisateur pour filtrer les indicateurs.
+    :param indicator_type: (facultatif) Type d'indicateur (ex. 'pca_items_count', 'pcoa_items_count').
+    :param category: (facultatif) Catégorie de l'indicateur (ex. 'CAT2-Engagement').
+    :param since_date: (facultatif) Date depuis laquelle les annotations sont considérées.
+    :param past_date: (facultatif) Date limite pour rechercher les indicateurs passés.
+    :return: Liste des indicateurs ou une valeur spécifique si `past_date` est fourni.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return [] if not past_date else 0
+
+    try:
+        indicators_table = Table('indicators', MetaData(), autoload_with=engine) 
+        query = session.query(indicators_table)
+        
+        # Ajout des filtres en fonction des paramètres fournis
+        if user_id:
+            query = query.filter(indicators_table.c.user_id == user_id)
+        
+        if indicator_type:
+            query = query.filter(indicators_table.c.type == indicator_type)
+
+        if category:
+            query = query.filter(indicators_table.c.category == category)
+
+        if since_date:
+            query = query.filter(indicators_table.c.date >= since_date)
+
+        if past_date:
+            query = query.filter(indicators_table.c.date <= past_date).order_by(indicators_table.c.date.desc()).limit(1)
+
+        # Exécution de la requête
+        result = pd.read_sql(query.statement, engine)
+
+        # Si on cherche un indicateur spécifique dans le passé, retourner une valeur ou 0
+        if past_date:
+            return result['value'].values[0] if not result.empty else 0
+       
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des indicateurs : {e}")
+        return [] if not past_date else 0
+    finally:
+        session.close()
+        engine.dispose()
+
+    return result
+
+
+def fetch_category_recommendations(category, since_date=None):
+    """
+    Récupère les indicateurs d'utilisateur depuis une date donnée (ou depuis le début si `since_date` n'est pas spécifié).
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date depuis laquelle les annotations sont considérées (facultatif).
+    :return: Liste des indicateurs avec leurs dates de création et de modification.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+    
+    try:
+        recos_table = Table('recommendations', MetaData(), autoload_with=engine)
+        query = (
+            session.query(recos_table.c.id, recos_table.c.user_id, recos_table.c.category,
+                          recos_table.c.strategy, recos_table.c.recommendation, recos_table.c.created_at)
+            .filter(recos_table.c.category == category)
+        )
+        if since_date:
+            query = query.filter(recos_table.c.created_at >= since_date)
+
+        result = pd.read_sql(query.statement, engine)
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des recos : {e}")
+        result = []
+    finally:
+        session.close()
+        engine.dispose()
+
+    return result
+
+def fetch_category_indicators(category, since_date=None):
+    """
+    Récupère les indicateurs d'utilisateur depuis une date donnée (ou depuis le début si `since_date` n'est pas spécifié).
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date depuis laquelle les annotations sont considérées (facultatif).
+    :return: Liste des indicateurs avec leurs dates de création et de modification.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return []
+
+    try:
+        indicators_table = Table('indicators', MetaData(), autoload_with=engine)
+        query = (
+            session.query(indicators_table.c.id, indicators_table.c.user_id, indicators_table.c.category,
+                          indicators_table.c.strategy, indicators_table.c.type, indicators_table.c.value,
+                          indicators_table.c.date)
+            .filter(indicators_table.c.category == category)
+        )
+        if since_date:
+            query = query.filter(indicators_table.c.date >= since_date)
+
+        result = pd.read_sql(query.statement, engine)
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération des indicateurs : {e}")
+        result = []
+    finally:
+        session.close()
+        engine.dispose()
+
+    return result
+
+def get_indicator_value(indicators_df, indicator_type, offset=0):
+    """
+    Récupère la valeur d'un indicateur spécifique à partir du DataFrame des indicateurs.
+    Si la valeur est une chaîne de caractères représentant un nombre, la convertit en nombre.
+    
+    :param indicators_df: DataFrame contenant les indicateurs.
+    :param indicator_type: Type d'indicateur dont la valeur est demandée.
+    :param offset: Décalage pour récupérer une valeur plus ancienne (0 pour la plus récente, 1 pour celle d'avant, etc.).
+    :return: Valeur de l'indicateur ou None si l'indicateur n'existe pas ou si l'offset est trop grand.
+    """
+    # Filtrer le DataFrame pour le type d'indicateur demandé et trier par date
+    indicator_rows = indicators_df[indicators_df['type'] == indicator_type].sort_values(by='date', ascending=False)
+
+    if indicator_rows.empty:
+        return None
+
+    # Vérifier si l'offset est valide
+    if offset >= len(indicator_rows):
+        return None
+
+    # Extraire la valeur de la ligne correspondant à l'offset
+    value = indicator_rows.iloc[offset]['value']
+
+    # Convertir la valeur en nombre si c'est une chaîne représentant un nombre
+    if isinstance(value, str):
+        try:
+            value = float(value)
+        except ValueError:
+            # Retourner None si la chaîne ne peut pas être convertie en nombre
+            return None
+
+    return value
+
+
+
+
+def fetch_user_profile(user_id):
+    """
+    Récupère le profil utilisateur sous forme de DataFrame.
+    
+    :param user_id: L'identifiant de l'utilisateur.
+    :return: Un DataFrame contenant les informations du profil utilisateur.
+    """
+    session, engine = connect_to_local_database()
+    if not session or not engine:
+        return pd.DataFrame()  # Retourner un DataFrame vide en cas de problème avec la connexion
+
+    try:
+        # Charger la table 'user_profiles'
+        user_profile_table = Table('user_profiles', MetaData(), autoload_with=engine)
+        
+        # Créer une requête filtrée par user_id
+        query = session.query(user_profile_table).filter_by(user_id=user_id)
+        
+        # Exécuter la requête et convertir le résultat en DataFrame
+        data = pd.read_sql(query.statement, engine)
+        
+        return data  # Retourner les données sous forme de DataFrame
+    except SQLAlchemyError as e:
+        print(f"Erreur lors de la récupération du profil utilisateur : {e}")
+        return pd.DataFrame()  # Retourner un DataFrame vide en cas d'erreur
\ No newline at end of file
diff --git a/rs/modules/es_operations.py b/rs/modules/es_operations.py
new file mode 100644
index 0000000000000000000000000000000000000000..1a0217ce39a32aa8a26066710af2d0913abc184c
--- /dev/null
+++ b/rs/modules/es_operations.py
@@ -0,0 +1,1513 @@
+from elasticsearch import Elasticsearch
+from flask import current_app
+import numpy as np
+import pandas as pd
+import seaborn as sns
+import matplotlib.pyplot as plt
+from elasticsearch.helpers import scan
+from pandas import json_normalize 
+from datetime import datetime, timedelta
+from geopy.distance import geodesic
+from sklearn.cluster import DBSCAN
+from geopy.distance import geodesic
+from datetime import datetime
+from datetime import datetime, timedelta
+
+# Instanciation du client
+es_client = None
+
+
+def get_elasticsearch_client():
+    global es_client
+    if es_client is None:
+        try:
+            elastic_config = current_app.config['ELASTIC_URI']
+            es_client = Elasticsearch(
+                [{'host': elastic_config['host'], 'port': elastic_config['port'], 'scheme': elastic_config['scheme']}],
+                http_auth=(elastic_config['username'], elastic_config['password'])
+            )
+        except ConnectionError as e:
+            print(f"Error connecting to Elasticsearch: {e}")
+            es_client = None
+    return es_client
+
+# Fetch actions for a user
+def fetch_actions(user_id, start_date, days):
+    """
+    Fetch user actions from Elasticsearch within a specified date range.
+
+    :param user_id: ID of the user.
+    :param start_date: Start date for the range, can be in "YYYY-MM-DD" format or as a timestamp.
+    :param days: Number of days from the start_date to fetch actions.
+    :return: List of user actions.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+
+    # Detect if start_date is a timestamp or date string
+    if isinstance(start_date, (int, float)):  # If timestamp
+        start_date_dt = datetime.fromtimestamp(start_date)
+    elif isinstance(start_date, str):  # If date string
+        start_date_dt = datetime.strptime(start_date, "%Y-%m-%d")
+    else:
+        raise ValueError("Invalid start_date format. Use 'YYYY-MM-DD' or a timestamp.")
+
+    end_date_dt = start_date_dt + timedelta(days=days)
+    
+    # Format dates to ISO 8601
+    start_date_str = start_date_dt.isoformat() + 'Z'
+    end_date_str = end_date_dt.isoformat() + 'Z'
+
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"range": {"begin": {"gte": start_date_str, "lte": end_date_str}}}
+                ],
+                "filter": {
+                    "terms": {
+                        "type": [
+                            'm:openPost', 'm:openMessage',
+                            'm:addPost', 'm:editPost',
+                            'm:startTour', 'm:addMessage', 'm:addEmoji',
+                            'm:movePost', 'm:editTour', 'm:addFavorite',
+                            'm:moveTour', 'm:addressSearch', 'm:Filter',
+                            'm:deletePost', 'm:deleteMessage'
+                        ]
+                    }
+                }
+            }
+        }
+    }
+
+    response = es.search(index="mobiles", body=query)
+    actions = response['hits']['hits']
+    
+    
+    return actions
+
+# Fetch initial shares for a user
+def fetch_initial_shares(user_id, start_date, days):
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    # Detect if start_date is a timestamp or date string
+    if isinstance(start_date, (int, float)):  # If timestamp
+        start_date_dt = datetime.fromtimestamp(start_date)
+    elif isinstance(start_date, str):  # If date string
+        start_date_dt = datetime.strptime(start_date, "%Y-%m-%d")
+    else:
+        raise ValueError("Invalid start_date format. Use 'YYYY-MM-DD' or a timestamp.")
+
+    end_date_dt = start_date_dt + timedelta(days=days)
+    
+    # Format dates to ISO 8601
+    start_date_str = start_date_dt.isoformat() + 'Z'
+    end_date_str = end_date_dt.isoformat() + 'Z'
+
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"range": {"begin": {"gte": start_date_str, "lte": end_date_str}}}
+                ],
+                "filter": {
+                    "terms": {
+                        "type": [
+                            'm:addPost', 'm:addMessage', 'm:addTour'
+                        ]
+                    }
+                }
+            }
+        }
+    }
+    response = es.search(index="mobiles", body=query)
+    shares = response['hits']['hits']
+    
+    return shares
+
+def fetch_discovered_locations(user_id, start_date=None, days=None):
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+
+    # Vérification et conversion du format de start_date si elle est fournie
+    if start_date:
+        if isinstance(start_date, (int, float)):  # Si start_date est un timestamp
+            start_date_dt = datetime.fromtimestamp(start_date)
+        elif isinstance(start_date, str):  # Si start_date est une chaîne de caractères
+            try:
+                start_date_dt = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S")
+            except ValueError:
+                try:
+                    start_date_dt = datetime.strptime(start_date, "%Y-%m-%d")
+                except ValueError:
+                    raise ValueError("Invalid start_date format. Use 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS', or a timestamp.")
+        elif isinstance(start_date, datetime):  # Si start_date est déjà un objet datetime
+            start_date_dt = start_date
+        else:
+            raise ValueError("Invalid start_date type. It should be a string, timestamp, or datetime object.")
+    else:
+        start_date_dt = None
+
+    # Calcul de la date de fin si days est fourni
+    if days:
+        end_date_dt = start_date_dt + timedelta(days=days) if start_date_dt else datetime.utcnow()
+    else:
+        end_date_dt = None
+
+    # Formatage des dates en ISO 8601
+    start_date_str = start_date_dt.isoformat() + 'Z' if start_date_dt else None
+    end_date_str = end_date_dt.isoformat() + 'Z' if end_date_dt else None
+
+    # Construction de la requête Elasticsearch
+    must_conditions = [{"term": {"userId": user_id}}]
+
+    if start_date_str:
+        range_query = {"begin": {"gte": start_date_str}}
+        if end_date_str:
+            range_query["begin"]["lte"] = end_date_str
+        must_conditions.append({"range": range_query})
+
+    query = {
+        "query": {
+            "bool": {
+                "must": must_conditions,
+                "should": [
+                    {"term": {"type": "m:startTour"}},
+                    {"term": {"type": "m:addPost"}},
+                    {"term": {"type": "m:openPost"}}
+                ],
+                "minimum_should_match": 1
+            }
+        }
+    }
+
+    # Exécution de la requête Elasticsearch
+    response = es.search(index="mobiles", body=query)
+    actions = response['hits']['hits']
+
+    # Extraction des coordonnées selon le type d'événement
+    coordinates = []
+    for action in actions:
+        source = action['_source']
+        if source['type'] == "m:addPost" and 'coordinates' in source:
+            coordinates.append(source['coordinates'])
+        elif source['type'] == "m:openPost" and 'post_details' in source and 'coordinates' in source['post_details']:
+            coordinates.append(source['post_details']['coordinates'])
+        elif source['type'] == "m:startTour" and 'position' in source:
+            coordinates.append(source['position'])
+
+    # Vérification si aucune coordonnée n'est trouvée
+    if not coordinates:
+        return []
+
+    # Convertir les coordonnées en tableau numpy
+    coords_array = np.array(coordinates)
+
+    # Conversion en liste de listes
+    coords_list = [[coord['lon'], coord['lat']] for coord in coords_array]
+
+    # Appliquer DBSCAN pour regrouper les points proches
+    db = DBSCAN(eps=0.01, min_samples=1).fit(coords_list)
+    labels = db.labels_
+
+    # Regrouper les coordonnées par cluster
+    discovered_locations = []
+    for label in set(labels):
+        cluster_points = np.array([coords_list[i] for i in range(len(labels)) if labels[i] == label])
+        if cluster_points.size > 0:
+            discovered_locations.append({
+                "cluster_id": label,
+                "coordinates": np.mean(cluster_points, axis=0).tolist(),  # Calculer le centre du cluster
+                "count": len(cluster_points)
+            })
+
+    print(f"Clusters découverts: {discovered_locations}")
+    return discovered_locations
+
+# Fetch created annotations for a user
+def fetch_content_creation(user_id, start_date=None, days=None):
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+
+    # Vérification et conversion du format de start_date si elle est fournie
+    if start_date:
+        if isinstance(start_date, (int, float)):  # Si start_date est un timestamp
+            start_date_dt = datetime.fromtimestamp(start_date)
+        elif isinstance(start_date, str):  # Si start_date est une chaîne de caractères
+            try:
+                # Tenter de convertir en format datetime à partir de "YYYY-MM-DD HH:MM:SS"
+                start_date_dt = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S")
+            except ValueError:
+                try:
+                    # Si le premier format échoue, essayer "YYYY-MM-DD"
+                    start_date_dt = datetime.strptime(start_date, "%Y-%m-%d")
+                except ValueError:
+                    raise ValueError("Invalid start_date format. Use 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS', or a timestamp.")
+        elif isinstance(start_date, datetime):  # Si start_date est déjà un objet datetime
+            start_date_dt = start_date
+        else:
+            raise ValueError("Invalid start_date type. It should be a string, timestamp, or datetime object.")
+    else:
+        # Si start_date n'est pas fournie, on récupère toutes les données jusqu'à aujourd'hui
+        start_date_dt = None
+
+    # Calcul de la date de fin si days est fourni
+    if days:
+        end_date_dt = start_date_dt + timedelta(days=days) if start_date_dt else datetime.utcnow()
+    else:
+        # Si days n'est pas fourni, end_date_dt n'est pas défini
+        end_date_dt = None
+
+    # Formatage des dates en ISO 8601
+    start_date_str = start_date_dt.isoformat() + 'Z' if start_date_dt else None
+    end_date_str = end_date_dt.isoformat() + 'Z' if end_date_dt else None
+
+    # Construction de la requête Elasticsearch
+    range_query = {}
+    if start_date_str and end_date_str:
+        range_query = {"begin": {"gte": start_date_str, "lte": end_date_str}}
+    elif start_date_str:
+        range_query = {"begin": {"gte": start_date_str}}
+    elif end_date_str:
+        range_query = {"begin": {"lte": end_date_str}}
+
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"range": range_query} if range_query else {}
+                ],
+                "filter": {
+                    "terms": {
+                        "type": [
+                            'm:addPost', 'm:addMessage', 'm:editPost',
+                            'm:addEmoji', 'm:movePost'
+                        ]
+                    }
+                }
+            }
+        }
+    }
+
+    # Exécution de la requête Elasticsearch
+    response = es.search(index="mobiles", body=query)
+    annotations = response['hits']['hits']
+
+    return annotations
+
+def fetch_route_creation(user_id, start_date=None, days=None):
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+
+    # Préparer les paramètres de la plage de dates
+    end_date_dt = None
+    if start_date and days:
+        # Utiliser les deux
+        start_date_dt = datetime.strptime(start_date, "%Y-%m-%d") if isinstance(start_date, str) else start_date
+        end_date_dt = start_date_dt + timedelta(days=days)
+    elif days:
+        # Utiliser uniquement days jours à partir d'aujourd'hui
+        end_date_dt = datetime.utcnow()
+        start_date_dt = end_date_dt - timedelta(days=days)
+    elif start_date:
+        # Utiliser uniquement start_date jusqu'à maintenant
+        start_date_dt = datetime.strptime(start_date, "%Y-%m-%d") if isinstance(start_date, str) else start_date
+
+    # Construire la requête Elasticsearch
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}}
+                ],
+                "filter": {
+                    "terms": {
+                        "type": ['m:startTour', 'm:editTour', 'm:moveTour']
+                    }
+                }
+            }
+        }
+    }
+
+    # Ajouter la plage de dates si disponible
+    if start_date_dt:
+        date_range = {"gte": start_date_dt.isoformat() + 'Z'}
+        if end_date_dt:
+            date_range["lte"] = end_date_dt.isoformat() + 'Z'
+        query["query"]["bool"]["must"].append({"range": {"begin": date_range}})
+
+    # Rechercher dans Elasticsearch
+    response = es.search(index="mobiles", body=query)
+    routes = response['hits']['hits']
+    
+    return routes
+
+
+def fetch_user_ids():
+    #TODO: supprime ça
+    print('Fetch users : nous testons avec juste un utilisateur')
+    user_ids = ['131']
+    return user_ids
+    """
+    Récupère tous les user_id uniques depuis l'index 'mobiles' dans Elasticsearch.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    query = {
+        "size": 0,
+        "aggs": {
+            "unique_users": {
+                "terms": {
+                    "field": "userId",
+                    "size": 10000
+                }
+            }
+        }
+    }
+    response = es.search(index='mobiles', body=query)
+    
+    user_ids = [bucket['key'] for bucket in response['aggregations']['unique_users']['buckets']]
+    return user_ids
+
+def fetch_logs_for_user(user_id):
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"range": {"timestamp": {"gte": "now-30d/d"}}}
+                ]
+            }
+        }
+    }
+    try:
+        response = es.search(index="mobiles", body=query)
+        return response['hits']['hits']
+    except ConnectionError as e:
+        print(f"Error fetching logs: {e}")
+        return []
+
+def fetch_annotations():
+    # Connect to the database to fetch annotations
+    from db_operations import fetch_annotations
+    return fetch_annotations()
+
+def calculate_distance(coord1, coord2):
+    if not coord1 or not coord2:
+        # Handle the case of empty coordinates
+        return None
+    lat1, lon1 = coord1['lat'], coord1['lon']
+    lat2, lon2 = coord2['lat'], coord2['lon']
+    return geodesic((lat1, lon1), (lat2, lon2)).km
+
+def fetch_user_logs(user_id=None, start_date=None, end_date=None):
+    """
+    Récupère les logs d'activité d'un utilisateur pour une période donnée depuis Elasticsearch.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    query = {
+        "query": {
+            "bool": {
+                "must": []
+            }
+        }
+    }
+    if user_id:
+        query["query"]["bool"]["must"].append({"term": {"userId": user_id}})
+    if start_date:
+        query["query"]["bool"]["must"].append({"range": {"timestamp": {"gte": start_date}}})
+    if end_date:
+        query["query"]["bool"]["must"].append({"range": {"timestamp": {"lte": end_date}}})
+    
+    response = es.search(index="mobiles", body=query, size=10000)
+    # es.close()
+    return response['hits']['hits']
+
+def fetch_user_logs_by_type(user_id=None, start_date=None, end_date=None, log_type=None):
+    """
+    Récupère les logs d'activité d'un utilisateur pour une période donnée depuis Elasticsearch, filtrés par type.
+    
+    :param user_id: ID de l'utilisateur
+    :param start_date: Date de début de la période
+    :param end_date: Date de fin de la période
+    :param log_type: Type de log à filtrer
+    :return: Liste des logs d'activité filtrés
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    query = {
+        "query": {
+            "bool": {
+                "must": []
+            }
+        }
+    }
+    
+    if user_id:
+        query["query"]["bool"]["must"].append({"term": {"userId": user_id}})
+    if start_date:
+        query["query"]["bool"]["must"].append({"range": {"timestamp": {"gte": start_date}}})
+    if end_date:
+        query["query"]["bool"]["must"].append({"range": {"timestamp": {"lte": end_date}}})
+    if log_type:
+        query["query"]["bool"]["must"].append({"term": {"type": log_type}})
+    
+    response = es.search(index="mobiles", body=query, size=10000)
+    return response['hits']['hits']
+
+# def fetch_user_logs(user_id, start_date, end_date):
+#     """
+#     Récupère les logs d'activité d'un utilisateur pour une période donnée depuis Elasticsearch.
+#     """
+#     es = get_elasticsearch_client()
+#     if es is None:
+#         return []
+#     query = {
+#         "query": {
+#             "bool": {
+#                 "must": [
+#                     {"term": {"userId": user_id}},
+#                     {"range": {"timestamp": {"gte": start_date, "lte": end_date}}}
+#                 ]
+#             }
+#         }
+#     }
+#     response = es.search(index="mobiles", body=query, size=10000)
+#     # es.close()
+#     return response['hits']['hits']
+
+def fetch_user_logs_with_time(user_id, start_date):
+    """
+    Récupère les logs d'activité d'un utilisateur avec estimation du temps passé sur chaque annotation.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    # Requête pour obtenir les logs de l'utilisateur avec la date de début
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"range": {"begin": {"gte": start_date}}}
+                ]
+            }
+        }
+    }
+
+    response = es.search(index="mobiles", body=query, size=1000)  # Ajuster la taille si nécessaire
+    # es.close()
+    
+    return response['hits']['hits']
+
+def fetch_user_filters(user_id, since_date=None):
+    """
+    Récupère les logs de filtrage d'un utilisateur.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"term": {"type": "m:Filter"}}
+                ],
+                # Ajoutez le filtre ici
+                "filter": []
+            }
+        }
+    }
+
+    # Filtrer par date si une date de début est spécifiée
+    if since_date:
+        date_filter = {
+            "range": {
+                "begin": {
+                    "gte": since_date
+                }
+            }
+        }
+        query["query"]["bool"]["filter"].append(date_filter)
+
+    # Effectuer la recherche dans Elasticsearch
+    try:
+        results = es.search(index="mobiles", body=query, size=1000)  # Ajustez la taille si nécessaire
+        # Extraire les documents renvoyés par Elasticsearch
+        hits = results['hits']['hits']
+        filter_data = []
+
+        for hit in hits:
+            filter_data.append(hit['_source'])
+
+        # Convertir les données en DataFrame
+        df_filters = pd.DataFrame(filter_data)
+        return df_filters
+    except Exception as e:
+        print(f"Erreur lors de la récupération des filtres utilisateur : {e}")
+        return pd.DataFrame()
+
+
+
+def fetch_user_actions(user_id, start_date=None, action_type=None):
+    """
+    Récupère les logs d'activité d'un utilisateur à partir de Elasticsearch.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}}
+                ]
+            }
+        },
+        "sort": [{"begin": {"order": "asc"}}]
+    }
+
+    if start_date:
+        query["query"]["bool"]["must"].append({"range": {"begin": {"gte": start_date}}})
+
+    if action_type:
+        query["query"]["bool"]["must"].append({"term": {"type": action_type}})
+
+    response = es.search(index="mobiles", body=query, size=1000)  # Ajuster la taille si nécessaire
+    # es.close()
+    
+    return response['hits']['hits']
+
+
+def fetch_interaction_count_for_content(user_id, content_id):
+    """
+    Récupère le nombre d'interactions d'un utilisateur spécifique avec un contenu donné.
+
+    :param user_id: ID de l'utilisateur
+    :param content_id: ID du contenu (annotation ou autre)
+    :return: Nombre d'interactions
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return 0
+
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"term": {"postId": content_id}},
+                    {"term": {"type": "m:openPost"}}  # Type d'interaction
+                ]
+            }
+        }
+    }
+    
+    response = es.search(index="mobiles", body=query, size=10000)
+    return len(response['hits']['hits'])  # Nombre de résultats retournés
+
+def fetch_interaction_count_for_content(content_id, user_id=None):
+    """
+    Récupère le nombre d'interactions avec un contenu donné.
+    Si user_id est fourni, filtre les interactions pour cet utilisateur spécifique.
+    Si user_id n'est pas fourni, récupère les interactions pour le contenu sans filtrer par utilisateur.
+
+    :param user_id: ID de l'utilisateur (facultatif)
+    :param content_id: ID du contenu (annotation ou autre)
+    :return: Nombre d'interactions
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return 0
+
+    # Construire la requête de base pour filtrer par content_id et type d'interaction
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"postId": content_id}},
+                    {"term": {"type": "m:openPost"}}  # Type d'interaction
+                ]
+            }
+        }
+    }
+
+    # Ajouter un filtre pour user_id uniquement s'il est fourni
+    if user_id is not None:
+        query['query']['bool']['must'].append({"term": {"userId": user_id}})
+
+    # Print the query for debugging
+    
+
+    try:
+        response = es.search(index="mobiles", body=query, size=10000)
+        return len(response['hits']['hits'])
+    except Exception as e:
+        print(f"Error performing search: {e}")
+        return 0
+
+    
+def fetch_actions_count(user_id, action_type):
+    """
+    Récupère le nombre d'actions spécifiques d'un utilisateur.
+
+    :param user_id: ID de l'utilisateur
+    :param action_type: Type d'action (ex: m:addPost, m:editPost, m:deletePost)
+    :return: Nombre d'actions
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return 0
+
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"term": {"type": action_type}}  # Type d'interaction
+                ]
+            }
+        }
+    }
+    
+    response = es.search(index="mobiles", body=query, size=10000)
+    return len(response['hits']['hits'])  # Nombre de résultats retournés
+
+
+def reconstruct_sessions(user_id, start_date):
+    """
+    Reconstruit les sessions de 10 minutes à partir des logs d'activité d'un utilisateur.
+    """
+    actions = fetch_user_actions(user_id, start_date)
+    
+    sessions = []
+    current_session = []
+    last_action_time = None
+
+    for action in actions:
+        action_time = datetime.fromisoformat(action['_source']['begin'].replace("Z", "+00:00"))
+        
+        if last_action_time and (action_time - last_action_time) > timedelta(minutes=10):
+            if current_session:
+                sessions.append(current_session)
+                current_session = []
+        
+        current_session.append(action)
+        last_action_time = action_time
+    
+    if current_session:
+        sessions.append(current_session)
+    
+    return sessions
+
+def fetch_content_popularity():
+    """
+    Récupère les indices de popularité des lieux, annotations et parcours à partir de Elasticsearch.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    queries = {
+        "annotations": {
+            "index": "mobiles",
+            "body": {
+                "aggs": {
+                    "annotations": {
+                        "terms": {"field": "annotation_id", "size": 1000},
+                        "aggs": {
+                            "open_post_count": {"filter": {"term": {"type": "m:openPost"}}},
+                            "see_tour_count": {"filter": {"term": {"type": "m:seeTour"}}}
+                        }
+                    }
+                }
+            }
+        },
+        "tours": {
+            "index": "mobiles",
+            "body": {
+                "aggs": {
+                    "tours": {
+                        "terms": {"field": "tour_id", "size": 1000},
+                        "aggs": {
+                            "open_post_count": {"filter": {"term": {"type": "m:openPost"}}},
+                            "see_tour_count": {"filter": {"term": {"type": "m:seeTour"}}}
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    content_popularity = {}
+    for content_type, query in queries.items():
+        response = es.search(index=query["index"], body=query["body"], size=0)
+        buckets = response["aggregations"][content_type]["buckets"]
+        content_popularity[content_type] = {
+            bucket["key"]: {
+                "open_post_count": bucket["open_post_count"]["doc_count"],
+                "see_tour_count": bucket["see_tour_count"]["doc_count"]
+            }
+            for bucket in buckets
+        }
+    
+    # es.close()
+    return content_popularity
+
+def fetch_interactions_by_content(index, content_ids):
+    """
+    Récupère les interactions globales pour une liste de contenus (annotations, lieux, parcours).
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+
+    query = {
+        "query": {
+            "bool": {
+                "should": [
+                    {"terms": {"content_id": content_ids}},
+                    {"terms": {"type": ["m:openPost", "m:seeTour"]}}
+                ]
+            }
+        },
+        "aggs": {
+            "by_content": {
+                "terms": {
+                    "field": "content_id",
+                    "size": len(content_ids)
+                },
+                "aggs": {
+                    "interaction_count": {
+                        "value_count": {
+                            "field": "type"
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    response = es.search(index=index, body=query)
+    # es.close()
+
+    content_popularity = {}
+    for bucket in response['aggregations']['by_content']['buckets']:
+        content_id = bucket['key']
+        interaction_count = bucket['interaction_count']['value']
+        content_popularity[content_id] = interaction_count
+    
+    return content_popularity
+
+def fetch_new_interactions(index, since_date):
+    """
+    Récupère le nombre d'interactions globales avec les nouveautés depuis une date donnée.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"range": {"begin": {"gte": since_date}}}
+                ],
+                "should": [
+                    {"terms": {"type": ["m:openPost", "m:seeTour", "m:Filter"]}}
+                ]
+            }
+        },
+        "aggs": {
+            "by_content": {
+                "terms": {
+                    "field": "content_id",
+                    "size": 10000  # Ajustez la taille selon vos besoins
+                },
+                "aggs": {
+                    "interaction_count": {
+                        "value_count": {
+                            "field": "type"
+                        }
+                    }
+                }
+            }
+        }
+    }
+    
+    response = es.search(index=index, body=query)
+    # es.close()
+    
+    new_interactions = {}
+    for bucket in response['aggregations']['by_content']['buckets']:
+        content_id = bucket['key']
+        interaction_count = bucket['interaction_count']['value']
+        new_interactions[content_id] = interaction_count
+    
+    return new_interactions
+
+from elasticsearch import Elasticsearch, exceptions
+
+def fetch_annotation_popularity():
+    """
+    Récupère la popularité des annotations en comptant le nombre de fois où elles ont été ouvertes (m:openPost)
+    et calcule le pourcentage par rapport à toutes les vues.
+
+    :return: Liste de dictionnaires avec l'ID de l'annotation, le nombre de vues, et le pourcentage de vues.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        print("Elasticsearch client could not be created.")
+        return []
+
+    try:
+        query = {
+            "size": 0,  # Nous n'avons pas besoin des hits réels, juste des agrégations
+            "query": {
+                "term": {"type": "m:openPost"}
+            },
+            "aggs": {
+                "annotations": {
+                    "terms": {
+                        "field": "postId",
+                        "size": 10000  # Limite le nombre de résultats, ajustez si nécessaire
+                    }
+                },
+                "total_views": {
+                    "sum": {
+                        "field": "doc_count"
+                    }
+                }
+            }
+        }
+
+        response = es.search(index="mobiles", body=query)
+        buckets = response['aggregations']['annotations']['buckets']
+
+        # Calcul du nombre total de vues
+        total_views = sum([bucket['doc_count'] for bucket in buckets])
+
+        # Extraire les IDs des annotations, leurs nombres de vues, et calculer le pourcentage
+        popularity_data = []
+        for bucket in buckets:
+            annotation_id = bucket['key']
+            view_count = bucket['doc_count']
+            view_percentage = (view_count / total_views) * 100 if total_views > 0 else 0
+            popularity_data.append({
+                "annotation_id": annotation_id,
+                "view_count": view_count,
+                "view_percentage": view_percentage
+            })
+
+        return popularity_data
+
+    except exceptions.RequestError as e:
+        print(f"Request error: {e}")
+        return []
+    except exceptions.ConnectionError as e:
+        print(f"Connection error: {e}")
+        return []
+    except Exception as e:
+        print(f"Unexpected error: {e}")
+        return []
+
+def fetch_annotation_views(post_id, user_id):
+    """
+    Récupère le nombre de fois qu'une annotation a été ouverte (m:openPost),
+    en excluant les vues de l'auteur.
+
+    :param post_id: ID de l'annotation
+    :param user_id: ID de l'auteur de l'annotation
+    :return: Nombre de vues de l'annotation, 0 par défaut si aucune vue.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        print("Elasticsearch client could not be created.")
+        return 0
+
+    try:
+        query = {
+            "query": {
+                "bool": {
+                    "must": [
+                        {"term": {"type": "m:openPost"}},
+                        {"term": {"postId": post_id}},  # Filtrer par l'ID de l'annotation
+                    ],
+                    "must_not": [
+                        {"term": {"userId": user_id}}  # Exclure les vues de l'auteur
+                    ]
+                }
+            }
+        }
+
+        response = es.search(index="mobiles", body=query)
+        
+        # Compter le nombre de vues pour l'annotation
+        view_count = response['hits']['total']['value'] if 'hits' in response and 'total' in response['hits'] else 0
+        
+        return view_count
+
+    except exceptions.RequestError as e:
+        print(f"Request error: {e}")
+        return 0
+    except exceptions.ConnectionError as e:
+        print(f"Connection error: {e}")
+        return 0
+    except Exception as e:
+        print(f"Unexpected error: {e}")
+        return 0
+
+
+def fetch_interactions_by_content(content_type, content_id):
+    """
+    Récupère les interactions spécifiques basées sur le type de contenu et l'identifiant de contenu depuis Elasticsearch.
+    
+    Args:
+    - content_type (str): Le type de contenu ('annotation' ou 'tour').
+    - content_id (str): L'identifiant du contenu.
+    
+    Returns:
+    - list: Liste des interactions pour le contenu spécifié.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    # Définir le type d'interaction basé sur le type de contenu
+    if content_type == 'annotation':
+        interaction_types = ['m:openPost', 'm:addEmoji']  # Ajoutez d'autres types d'interactions spécifiques aux annotations
+    elif content_type == 'tour':
+        interaction_types = ['m:seeTour']  # Ajoutez d'autres types d'interactions spécifiques aux tours
+    else:
+        raise ValueError("content_type doit être 'annotation' ou 'tour'")
+    
+    # Requête Elasticsearch pour récupérer les interactions
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"content_id": content_id}},
+                    {"terms": {"type": interaction_types}}
+                ]
+            }
+        }
+    }
+    
+    response = es.search(index="mobiles", body=query)
+    interactions = response['hits']['hits']
+    
+    # es.close()
+    
+    return interactions
+
+def fetch_user_viewed_annotations(user_id, start_date=None, end_date=None):
+    """
+    Récupère la liste des annotations que l'utilisateur a déjà vues dans une plage de dates optionnelle.
+
+    :param user_id: ID de l'utilisateur.
+    :param start_date: Date de début pour le filtrage (optionnelle).
+    :param end_date: Date de fin pour le filtrage (optionnelle).
+    :return: Liste des IDs des annotations vues par l'utilisateur.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        print("Elasticsearch client could not be created.")
+        return []
+
+    try:
+        # Construction de la requête Elasticsearch
+        query = {
+            "size": 10000,  # Limite le nombre de résultats, ajustez si nécessaire
+            "query": {
+                "bool": {
+                    "must": [
+                        {"term": {"userId": user_id}},
+                        {"term": {"type": "m:openPost"}}
+                    ]
+                }
+            },
+            "aggs": {
+                "annotations": {
+                    "terms": {
+                        "field": "postId",
+                        "size": 10000  # Limite le nombre de résultats, ajustez si nécessaire
+                    }
+                }
+            }
+        }
+
+        # Si des dates sont fournies, ajouter une condition de filtrage par date
+        if start_date or end_date:
+            date_filter = {
+                "range": {
+                    "begin": {}
+                }
+            }
+            if start_date:
+                date_filter["range"]["begin"]["gte"] = start_date
+            if end_date:
+                date_filter["range"]["begin"]["lte"] = end_date
+
+            query["query"]["bool"]["filter"] = [date_filter]
+
+        # Exécution de la requête Elasticsearch
+        response = es.search(index="mobiles", body=query)
+        buckets = response['aggregations']['annotations']['buckets']
+        
+        # Extraction des IDs d'annotations consultées
+        viewed_annotation_ids = [bucket['key'] for bucket in buckets]
+        
+        return viewed_annotation_ids
+
+    except exceptions.RequestError as e:
+        print(f"Request error: {e}")
+        return []
+    except exceptions.ConnectionError as e:
+        print(f"Connection error: {e}")
+        return []
+    except Exception as e:
+        print(f"Unexpected error: {e}")
+        return []
+
+def fetch_user_viewed_tours(user_id, start_date=None, end_date=None):
+    """
+    Récupère la liste des tours que l'utilisateur a déjà vues dans une plage de dates optionnelle.
+
+    :param user_id: ID de l'utilisateur.
+    :param start_date: Date de début pour le filtrage (optionnelle).
+    :param end_date: Date de fin pour le filtrage (optionnelle).
+    :return: Liste des IDs des tours vues par l'utilisateur.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        print("Elasticsearch client could not be created.")
+        return []
+
+    try:
+        # Construction de la requête Elasticsearch
+        query = {
+            "size": 10000,  # Limite le nombre de résultats, ajustez si nécessaire
+            "query": {
+                "bool": {
+                    "must": [
+                        {"term": {"userId": user_id}},
+                        {"term": {"type": "m:seeTour"}}
+                    ]
+                }
+            },
+            "aggs": {
+                "tours": {
+                    "terms": {
+                        "field": "tourId",
+                        "size": 10000  # Limite le nombre de résultats, ajustez si nécessaire
+                    }
+                }
+            }
+        }
+
+        # Si des dates sont fournies, ajouter une condition de filtrage par date
+        if start_date or end_date:
+            date_filter = {
+                "range": {
+                    "begin": {}
+                }
+            }
+            if start_date:
+                date_filter["range"]["begin"]["gte"] = start_date
+            if end_date:
+                date_filter["range"]["begin"]["lte"] = end_date
+
+            query["query"]["bool"]["filter"] = [date_filter]
+
+        # Exécution de la requête Elasticsearch
+        response = es.search(index="mobiles", body=query)
+        buckets = response['aggregations']['tours']['buckets']
+        
+        # Extraction des IDs de tours consultés
+        viewed_tour_ids = [bucket['key'] for bucket in buckets]
+        
+        return viewed_tour_ids
+
+    except exceptions.RequestError as e:
+        print(f"Request error: {e}")
+        return []
+    except exceptions.ConnectionError as e:
+        print(f"Connection error: {e}")
+        return []
+    except Exception as e:
+        print(f"Unexpected error: {e}")
+        return []
+
+def fetch_annotations_around_location(location, radius='5km'):
+    """
+    Récupère les annotations autour d'une localisation donnée dans Elasticsearch.
+
+    :param location: Dictionnaire contenant les coordonnées avec les clés 'lat' et 'lon'.
+    :param radius: Rayon autour de la localisation pour la recherche des annotations.
+    :return: Liste des annotations trouvées.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    query = {
+        "query": {
+            "bool": {
+                "filter": {
+                    "geo_distance": {
+                        "distance": radius,
+                        "coordinates": {
+                            "lat": location['lat'],
+                            "lon": location['lon']
+                        }
+                    }
+                }
+            }
+        }
+    }
+    response = es.search(index='annotations', body=query)
+    # es.close()
+    return response['hits']['hits']
+
+def fetch_visited_locations(user_id):
+    """
+    Infère les lieux visités par un utilisateur en se basant sur les logs de session dans Elasticsearch.
+
+    :param user_id: ID de l'utilisateur pour lequel on souhaite récupérer les lieux visités.
+    :return: Liste des coordonnées des lieux visités.
+    """
+    
+    es = get_elasticsearch_client()
+    
+    if es is None:
+        return []
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"term": {"type": "m:openPost"}}  # Assumes 'm:openPost' is used for visit-like activities
+                ]
+            }
+        }
+    }
+    response = es.search(index='mobiles', body=query)
+    
+
+    visited_locations = []
+    for hit in response['hits']['hits']:
+        source = hit['_source']
+        if 'post_details' in source:
+            source = source['post_details']
+            if 'coordinates' in source:
+                visited_locations.append(source['coordinates'])
+    
+    return visited_locations
+
+
+def fetch_user_visits(user_id, since_date):
+    """
+    Récupère les visites des lieux par un utilisateur depuis une date donnée.
+
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les visites sont considérées.
+    :return: Liste des visites effectuées par l'utilisateur avec leurs coordonnées.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+    
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"range": {"begin": {"gte": since_date}}}
+                ],
+                "filter": {
+                    "exists": {
+                        "field": "coordinates"
+                    }
+                }
+            }
+        }
+    }
+    
+    response = es.search(index="mobiles", body=query, size=10000)
+    # es.close()
+    
+    visits = response['hits']['hits']
+    return [{'coordinates': hit['_source']['coordinates']} for hit in visits]
+
+# es_operations.py
+
+from elasticsearch import Elasticsearch
+
+def fetch_user_traces(user_id, since_date=None): 
+    """
+    Récupère les traces d'un utilisateur depuis une date donnée et retourne un DataFrame.
+
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les traces sont considérées. Si None, toutes les traces seront récupérées.
+    :return: DataFrame contenant les traces de l'utilisateur avec leurs coordonnées.
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return pd.DataFrame()  # Retourne un DataFrame vide si le client Elasticsearch n'est pas disponible
+    
+    # Initialisation de la requête de base
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}}
+                ]
+            }
+        }
+    }
+
+    # Ajout de la condition sur since_date si elle est fournie
+    if since_date is not None:
+        query['query']['bool']['must'].append({"range": {"timestamp": {"gte": since_date}}})
+
+    response = es.search(index="mobiles", body=query, size=10000)
+    
+    # Vérification que la réponse contient des hits
+    if 'hits' not in response or 'hits' not in response['hits']:
+        return pd.DataFrame()  # Retourne un DataFrame vide si aucun hit trouvé
+
+    traces = response['hits']['hits']
+    
+    # Extraction des hits contenant les coordinates
+    filtered_traces = [hit['_source'] for hit in traces if 'coordinates' in hit['_source']]
+    
+    # Retourne un DataFrame à partir de la liste des hits filtrés
+    return pd.DataFrame(filtered_traces)
+
+
+def convert_to_elasticsearch_format(date_str):
+    """
+    Convertit une date au format 'YYYY-MM-DD' en un format ISO 8601 pour Elasticsearch.
+    
+    :param date_str: Date sous forme de chaîne ('YYYY-MM-DD')
+    :return: Date au format ISO 8601
+    """
+    try:
+        # Convertir la date en objet datetime
+        date_obj = datetime.strptime(date_str, '%Y-%m-%d')
+        # Convertir l'objet datetime en chaîne de caractères ISO 8601
+        return date_obj.isoformat()
+    except ValueError:
+        raise ValueError("Format de date invalide. Utilisez 'YYYY-MM-DD'.")
+
+
+
+
+def calculate_end_date(start_date_str, days):
+    """
+    Calcule la date de fin en ajoutant un nombre de jours à une date de début.
+    
+    :param start_date_str: Date de début sous forme de chaîne ('YYYY-MM-DD')
+    :param days: Nombre de jours à ajouter
+    :return: Date de fin au format ISO 8601
+    """
+    try:
+        # Convertir la date de début en objet datetime
+        start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
+        # Ajouter le nombre de jours pour obtenir la date de fin
+        end_date = start_date + timedelta(days=days)
+        # Convertir la date de fin en chaîne de caractères ISO 8601
+        return end_date.isoformat()
+    except ValueError:
+        raise ValueError("Format de date invalide. Utilisez 'YYYY-MM-DD'.")
+
+
+from datetime import datetime, timedelta
+import traceback
+
+def fetch_user_locations(user_id, start_date=None, days=None):
+    """
+    Récupère les emplacements découverts (recherchés ou visités) par l'utilisateur dans un délai donné.
+
+    :param user_id: ID de l'utilisateur
+    :param start_date: Date de début (format YYYY-MM-DD ou timestamp). Par défaut, considère toutes les données disponibles.
+    :param days: Nombre de jours à partir de la date de début. Par défaut, considère toutes les données disponibles.
+    :return: Liste des emplacements uniques découverts par l'utilisateur
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return []
+
+    # Construire la requête de base
+    must_conditions = [{"term": {"userId": user_id}}]
+
+    # Ajouter les conditions de date si start_date est fourni
+    if start_date:
+        try:
+            # Convertir start_date en format Elasticsearch si nécessaire
+            if isinstance(start_date, str):
+                start_date = datetime.strptime(start_date, '%Y-%m-%d')
+            start_date_str = start_date.strftime('%Y-%m-%d')
+
+            if days:
+                end_date = (start_date + timedelta(days=days)).strftime('%Y-%m-%d')
+            else:
+                end_date = datetime.now().strftime('%Y-%m-%d')
+
+            must_conditions.append({"range": {"begin": {"gte": start_date_str, "lte": end_date}}})
+        except ValueError as e:
+            print("Format de date invalide pour start_date. Utilisez le format YYYY-MM-DD.")
+            return []
+
+    # Query Elasticsearch pour les actions pertinentes
+    query = {
+        "query": {
+            "bool": {
+                "must": must_conditions,
+                "filter": {
+                    "terms": {
+                        "type": ['m:addressSearch', 'm:addPost', 'm:startTour']
+                    }
+                }
+            }
+        }
+    }
+
+    try:
+        response = es.search(index="mobiles", body=query)
+        actions = response['hits']['hits']
+    except Exception as e:
+        print("Une erreur est survenue :")
+        print(str(e))
+        print("Détails de l'erreur :")
+        traceback.print_exc()
+        return []
+
+    # Extraire et dédupliquer les emplacements
+    locations = []
+    unique_locations = set()
+
+    for action in actions:
+        location = extract_location_from_action(action)
+
+        if location and not is_location_near(location, unique_locations):
+            unique_locations.add(location)
+            locations.append(location)
+
+    return locations
+
+
+def extract_location_from_action(action):
+    """
+    Extrait les coordonnées géographiques d'une action Elasticsearch.
+
+    :param action: Données d'action provenant d'Elasticsearch
+    :return: Tuple de coordonnées (latitude, longitude)
+    """
+    try:
+        lat = action['_source']['location']['lat']
+        lon = action['_source']['location']['lon']
+        return (lat, lon)
+    except KeyError:
+        return None
+
+def is_location_near(location, unique_locations, threshold=0.1):
+    """
+    Vérifie si un emplacement est à proximité d'un des emplacements existants.
+
+    :param location: Emplacement sous forme de tuple (lat, lon)
+    :param unique_locations: Ensemble des emplacements déjà identifiés
+    :param threshold: Distance en kilomètres pour considérer les emplacements comme proches
+    :return: Booléen indiquant si l'emplacement est proche d'un emplacement existant
+    """
+    for existing_location in unique_locations:
+        if geodesic(location, existing_location).km < threshold:
+            return True
+    return False
+
+def fetch_opened_posts(user_id):
+    """
+    Récupère les posts ouverts par un utilisateur depuis Elasticsearch.
+
+    :param user_id: ID de l'utilisateur
+    :return: DataFrame contenant les posts ouverts par l'utilisateur
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return pd.DataFrame()
+    
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"terms": {"type": ['m:openPost']}}
+                ]
+            }
+        }
+    }
+    
+    response = es.search(index="mobiles", body=query)
+    data = []
+    for hit in response['hits']['hits']:
+        source = hit['_source']
+        if 'coordinates' in source:
+            data.append(source['coordinates'])
+    
+    # Convert result to DataFrame
+    df = pd.DataFrame(data, columns=['coordinates'])
+    return df
+
+def fetch_address_searches(user_id):
+    """
+    Récupère les recherches d'adresse effectuées par un utilisateur depuis Elasticsearch.
+
+    :param user_id: ID de l'utilisateur
+    :return: DataFrame contenant les recherches d'adresse de l'utilisateur
+    """
+    es = get_elasticsearch_client()
+    if es is None:
+        return pd.DataFrame()
+    
+    query = {
+        "query": {
+            "bool": {
+                "must": [
+                    {"term": {"userId": user_id}},
+                    {"terms": {"type": ['m:addressSearch']}}
+                ]
+            }
+        }
+    }
+    
+    response = es.search(index="mobiles", body=query)
+    # data = [hit['_source']['coordinates'] for hit in response['hits']['hits']]
+    data = []
+    for hit in response['hits']['hits']:
+        source = hit['_source']
+        if 'coordinates' in source:
+            data.append(source['coordinates'])
+    
+    # Convert result to DataFrame
+    df = pd.DataFrame(data, columns=['coordinates'])
+    return df
\ No newline at end of file
diff --git a/rs/modules/generate_recommendations.py b/rs/modules/generate_recommendations.py
new file mode 100644
index 0000000000000000000000000000000000000000..d48d334f430c842429744a8a03b0339588212d0e
--- /dev/null
+++ b/rs/modules/generate_recommendations.py
@@ -0,0 +1,274 @@
+import pandas as pd
+from sqlalchemy import create_engine, Table, MetaData
+from datetime import datetime
+
+# Connexion à la base de données
+import pandas as pd
+from sqlalchemy import create_engine, MetaData, Table
+from datetime import datetime
+
+def recuperer_indicateurs(categorie: str, strategie: str) -> pd.DataFrame:
+    """
+    Récupère les indicateurs de la base de données pour une catégorie et une stratégie données.
+
+    :param categorie: Catégorie des indicateurs.
+    :param strategie: Stratégie des indicateurs.
+    :return: DataFrame avec les indicateurs.
+    """
+    engine = create_engine('sqlite:///database.db')  # Remplacez par l'URL de votre base de données
+    metadata = MetaData(bind=engine)
+    indicators_table = Table('indicators', metadata, autoload=True)
+    recommendations_table = Table('recommendations', metadata, autoload=True)
+
+    with engine.connect() as connection:
+        query = indicators_table.select().where(
+            (indicators_table.c.categorie == categorie) & 
+            (indicators_table.c.strategie == strategie)
+        )
+        result = connection.execute(query)
+        return pd.DataFrame(result.fetchall(), columns=result.keys())
+
+def enregistrer_recommandation(categorie: str, strategie: str, recommandation: str, date: str):
+    """
+    Enregistre une recommandation dans la table 'recommendations'.
+
+    :param categorie: Catégorie de la recommandation.
+    :param strategie: Stratégie associée à la recommandation.
+    :param recommandation: Contenu de la recommandation.
+    :param date: Date d'enregistrement.
+    """
+    engine = create_engine('sqlite:///database.db')  # Remplacez par l'URL de votre base de données
+    metadata = MetaData(bind=engine)
+    recommendations_table = Table('recommendations', metadata, autoload=True)
+
+    with engine.connect() as connection:
+        connection.execute(recommendations_table.insert().values(
+            categorie=categorie,
+            strategie=strategie,
+            recommandation=recommandation,
+            date=date
+        ))
+
+# Fonction pour vérifier les conditions de déclenchement pour chaque catégorie
+
+# Catégorie 1: Engagement Utilisateur
+def verifier_engagement_utilisateur():
+    """
+    Vérifie les conditions de déclenchement pour les stratégies de la catégorie 'Engagement Utilisateur'.
+    """
+    SEUILS = {
+        'Engagement Rate': 50,
+        'User Contribution Rate': 30,
+        'Activity Frequency': 5
+    }
+
+    strategies = list(SEUILS.keys())
+
+    for strategie in strategies:
+        df = recuperer_indicateurs('Engagement Utilisateur', strategie)
+        valeur = df['valeur'].mean()
+
+        if valeur < SEUILS[strategie]:
+            recommandations = {
+                'Engagement Rate': "Augmentez les interactions avec les utilisateurs pour améliorer l'engagement.",
+                'User Contribution Rate': "Encouragez les utilisateurs à contribuer davantage pour améliorer le taux de contribution.",
+                'Activity Frequency': "Augmentez la fréquence des activités pour améliorer l'engagement."
+            }
+            recommandation = recommandations[strategie]
+            enregistrer_recommandation('Engagement Utilisateur', strategie, recommandation, datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
+
+# Catégorie 2: Qualité des Contributions
+def verifier_qualite_contributions():
+    """
+    Vérifie les conditions de déclenchement pour les stratégies de la catégorie 'Qualité des Contributions'.
+    """
+    SEUILS = {
+        'Annotation Quality': 4,
+        'Parcours Quality': 4,
+        'Annotation Clarity': 4,
+        'Annotation Relevance': 4,
+        'Annotation Coverage': 80,
+        'Parcours Completeness': 80,
+        'Parcours Accuracy': 80,
+        'Parcours Relevance': 80,
+        'Parcours Clarity': 80,
+        'Annotation Experience': 4,
+        'Parcours Experience': 4
+    }
+
+    strategies = list(SEUILS.keys())
+
+    for strategie in strategies:
+        df = recuperer_indicateurs('Qualité des Contributions', strategie)
+        valeur = df['valeur'].mean()
+
+        if valeur < SEUILS[strategie]:
+            recommandations = {
+                'Annotation Quality': "Améliorez la qualité des annotations pour mieux répondre aux attentes des utilisateurs.",
+                'Parcours Quality': "Améliorez la qualité des parcours pour offrir une meilleure expérience utilisateur.",
+                'Annotation Clarity': "Augmentez la clarté des annotations pour une meilleure compréhension.",
+                'Annotation Relevance': "Assurez-vous que les annotations sont plus pertinentes pour les utilisateurs.",
+                'Annotation Coverage': "Augmentez la couverture des annotations pour inclure une plus grande diversité de contenu.",
+                'Parcours Completeness': "Assurez-vous que les parcours sont plus complets pour une meilleure expérience.",
+                'Parcours Accuracy': "Améliorez l'exactitude des parcours pour une meilleure précision.",
+                'Parcours Relevance': "Augmentez la pertinence des parcours pour mieux répondre aux besoins des utilisateurs.",
+                'Parcours Clarity': "Améliorez la clarté des parcours pour une meilleure compréhension.",
+                'Annotation Experience': "Améliorez l'expérience des utilisateurs lors de l'annotation pour une meilleure satisfaction.",
+                'Parcours Experience': "Améliorez l'expérience des utilisateurs lors de la navigation dans les parcours pour une meilleure satisfaction."
+            }
+            recommandation = recommandations[strategie]
+            enregistrer_recommandation('Qualité des Contributions', strategie, recommandation, datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
+
+# Catégorie 3: Interaction et Réflexion
+def verifier_interaction_reflexion():
+    """
+    Vérifie les conditions de déclenchement pour les stratégies de la catégorie 'Interaction et Réflexion'.
+    """
+    SEUILS = {
+        'Annotation Revisions': 10,
+        'Community Interaction': 5,
+        'Commenting Rate': 3,
+        'Annotation Quality Self-Reflection': 4,
+        'Self-Reflection Rate': 3,
+        'Improvement Rate': 10,
+        'Community Reflection Rate': 5,
+        'Community Engagement Depth': 4
+    }
+
+    strategies = list(SEUILS.keys())
+
+    for strategie in strategies:
+        df = recuperer_indicateurs('Interaction et Réflexion', strategie)
+        valeur = df['valeur'].mean()
+
+        if valeur < SEUILS[strategie]:
+            recommandations = {
+                'Annotation Revisions': "Augmentez les révisions des annotations pour améliorer la qualité globale.",
+                'Community Interaction': "Favorisez davantage d'interactions communautaires pour stimuler l'engagement.",
+                'Commenting Rate': "Encouragez les utilisateurs à commenter davantage pour améliorer l'interaction.",
+                'Annotation Quality Self-Reflection': "Encouragez les annotateurs à réfléchir davantage sur la qualité de leurs annotations.",
+                'Self-Reflection Rate': "Augmentez le taux de réflexion personnelle pour améliorer la qualité des annotations.",
+                'Improvement Rate': "Encouragez les utilisateurs à améliorer leurs contributions pour une meilleure qualité globale.",
+                'Community Reflection Rate': "Favorisez la réflexion communautaire pour améliorer les contributions globales.",
+                'Community Engagement Depth': "Augmentez la profondeur de l'engagement communautaire pour une meilleure interaction."
+            }
+            recommandation = recommandations[strategie]
+            enregistrer_recommandation('Interaction et Réflexion', strategie, recommandation, datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
+
+# Catégorie 4: Amélioration Continue et Performance de l'Application
+def verifier_performance_application():
+    """
+    Vérifie les conditions de déclenchement pour les stratégies de la catégorie 'Amélioration Continue et Performance de l'Application'.
+    """
+    SEUILS = {
+        'Recommendation Precision': 80,
+        'Recommendation Coverage': 70,
+        'Application Response Time': 2000,
+        'Error Rate': 5
+    }
+
+    strategies = list(SEUILS.keys())
+
+    for strategie in strategies:
+        df = recuperer_indicateurs('Amélioration Continue et Performance de l\'Application', strategie)
+        valeur = df['valeur'].mean()
+
+        if valeur < SEUILS[strategie]:
+            recommandations = {
+                'Recommendation Precision': "Améliorez la précision des recommandations pour mieux répondre aux besoins des utilisateurs.",
+                'Recommendation Coverage': "Augmentez la couverture des recommandations pour inclure une plus grande diversité de contenu.",
+                'Application Response Time': "Optimisez le temps de réponse de l'application pour améliorer l'expérience utilisateur.",
+                'Error Rate': "Réduisez le taux d'erreur de l'application pour améliorer la fiabilité."
+            }
+            recommandation = recommandations[strategie]
+            enregistrer_recommandation('Amélioration Continue et Performance de l\'Application', strategie, recommandation, datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
+
+# Catégorie 5: Initiation et Intégration des Utilisateurs
+def verifier_initiation_integration_utilisateur():
+    """
+    Vérifie les conditions de déclenchement pour les stratégies de la catégorie 'Initiation et Intégration des Utilisateurs'.
+    """
+    SEUILS = {
+        'Nombre de Connecteurs par Jour': 5,
+        'Durée de Session': 10,
+        'Nombre de Contributions par Session': 3
+    }
+
+    strategies = list(SEUILS.keys())
+
+    for strategie in strategies:
+        df = recuperer_indicateurs('Initiation et Intégration des Utilisateurs', strategie)
+        valeur = df['valeur'].mean()
+
+        if valeur < SEUILS[strategie]:
+            recommandations = {
+                'Nombre de Connecteurs par Jour': "Augmentez le nombre de connecteurs quotidiens pour une meilleure initiation.",
+                'Durée de Session': "Prolongez la durée des sessions pour améliorer l'engagement des utilisateurs.",
+                'Nombre de Contributions par Session': "Encouragez les utilisateurs à faire plus de contributions par session."
+            }
+            recommandation = recommandations[strategie]
+            enregistrer_recommandation('Initiation et Intégration des Utilisateurs', strategie, recommandation, datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
+
+# Catégorie 6: Évolution de l'Expérience Utilisateur
+def verifier_evolution_experience_utilisateur():
+    """
+    Vérifie les conditions de déclenchement pour les stratégies de la catégorie 'Évolution de l'Expérience Utilisateur'.
+    """
+    SEUILS = {
+        'Taux de Satisfaction des Utilisateurs': 4,
+        'Taux de Répétition des Utilisateurs': 20,
+        'Durée d\'Utilisation': 15
+    }
+
+    strategies = list(SEUILS.keys())
+
+    for strategie in strategies:
+        df = recuperer_indicateurs('Évolution de l\'Expérience Utilisateur', strategie)
+        valeur = df['valeur'].mean()
+
+        if valeur < SEUILS[strategie]:
+            recommandations = {
+                'Taux de Satisfaction des Utilisateurs': "Améliorez la satisfaction des utilisateurs pour une meilleure rétention.",
+                'Taux de Répétition des Utilisateurs': "Augmentez le taux de répétition des utilisateurs pour améliorer la fidélité.",
+                'Durée d\'Utilisation': "Prolongez la durée d'utilisation pour une meilleure expérience utilisateur."
+            }
+            recommandation = recommandations[strategie]
+            enregistrer_recommandation('Évolution de l\'Expérience Utilisateur', strategie, recommandation, datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
+
+# Catégorie 7: Rendement des Tâches et Optimisation des Ressources
+def verifier_rendement_taches():
+    """
+    Vérifie les conditions de déclenchement pour les stratégies de la catégorie 'Rendement des Tâches'.
+    """
+    SEUILS = {
+        'Taux d\'Achèvement des Tâches': 80,
+        'Durée d\'Achèvement des Tâches': 60,
+        'Qualité des Résultats des Tâches': 4
+    }
+
+    strategies = list(SEUILS.keys())
+
+    for strategie in strategies:
+        df = recuperer_indicateurs('Rendement des Tâches', strategie)
+        valeur = df['valeur'].mean()
+
+        if valeur < SEUILS[strategie]:
+            recommandations = {
+                'Taux d\'Achèvement des Tâches': "Augmentez le taux d'achèvement des tâches pour améliorer le rendement.",
+                'Durée d\'Achèvement des Tâches': "Réduisez la durée d'achèvement des tâches pour améliorer l'efficacité.",
+                'Qualité des Résultats des Tâches': "Améliorez la qualité des résultats des tâches pour mieux répondre aux attentes."
+            }
+            recommandation = recommandations[strategie]
+            enregistrer_recommandation('Rendement des Tâches', strategie, recommandation, datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
+
+def generate_recommendations():
+    """
+    Fonction principale pour exécuter les vérifications et générer les recommandations pour chaque catégorie.
+    """
+    verifier_engagement_utilisateur()  # Catégorie 1
+    verifier_qualite_contributions()  # Catégorie 2
+    verifier_interaction_reflexion()  # Catégorie 3
+    verifier_performance_application()  # Catégorie 4
+    verifier_initiation_integration_utilisateur()  # Catégorie 5
+    verifier_evolution_experience_utilisateur()  # Catégorie 6
+    verifier_rendement_taches()  # Catégorie 7
diff --git a/rs/modules/metrics/__init__.py b/rs/modules/metrics/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rs/modules/metrics/adoption_integration_m.py b/rs/modules/metrics/adoption_integration_m.py
new file mode 100644
index 0000000000000000000000000000000000000000..6774518da38ee23fcdcb963d2fb965b443c4ab80
--- /dev/null
+++ b/rs/modules/metrics/adoption_integration_m.py
@@ -0,0 +1,286 @@
+from datetime import datetime, timedelta
+from modules.db_operations import fetch_user_annotations, fetch_user_tours, get_user_registration_date, insert_indicator
+from modules.es_operations import fetch_actions_count, fetch_discovered_locations, fetch_actions, fetch_initial_shares, fetch_user_viewed_annotations, get_elasticsearch_client
+
+# --------------------------------------------
+# Catégorie 1 : Adoption et Intégration
+# --------------------------------------------
+
+# --------------------------------------------
+# Stratégie 1 : Soutien Initial (Démarrage à Froid)
+# --------------------------------------------
+
+def calculate_nia(user_id):
+    """
+    Calcul du NIA (Number of Initial Actions) pour un utilisateur donné.
+    """
+    registration_date_str = get_user_registration_date(user_id)
+    
+    if registration_date_str:
+        # Convertir la date d'inscription en objet datetime
+        try:
+            # Assumer que registration_date_str est au format 'YYYY-MM-DDTHH:MM:SS.sssZ'
+            registration_date = datetime.strptime(registration_date_str, "%Y-%m-%dT%H:%M:%S.%fZ")
+        except ValueError:
+            try:
+                # Essayer le format 'YYYY-MM-DD' si le premier essai échoue
+                registration_date = datetime.strptime(registration_date_str, "%Y-%m-%d")
+            except ValueError as e:
+                print(f"Erreur de format de date pour l'utilisateur {user_id}: {e}")
+                return
+        
+        # Obtenir la date actuelle
+        today = datetime.utcnow()
+        
+        # Vérifier si l'utilisateur s'est enregistré dans les 10 derniers jours
+        if today - registration_date <= timedelta(days=15000):
+            # Convertir la date d'inscription en format 'YYYY-MM-DD' pour fetch_actions
+            registration_date_str_for_query = registration_date.strftime("%Y-%m-%d")
+            
+            # Fetch actions performed within the first 70 days of registration
+            actions = fetch_actions(user_id, registration_date_str_for_query, 1500)
+            
+            # Compter le nombre d'actions
+            nia = len(actions)
+            
+            # Insérer l'indicateur NIA
+            insert_indicator(user_id, "CAT1-Adoption_Integration", "Number of Initial Actions", "NIA", nia)
+        else:
+            print(f"L'utilisateur {user_id} ne s'est pas enregistré dans les 10 derniers jours.")
+
+
+def calculate_nis(user_id):
+    """
+    Calcul du NIS (Number of Initial Shares) pour un utilisateur donné.
+    """
+    registration_date_str = get_user_registration_date(user_id)
+    
+    if registration_date_str:
+        try:
+            # Assumer que registration_date_str est au format 'YYYY-MM-DDTHH:MM:SS.sssZ'
+            registration_date = datetime.strptime(registration_date_str, "%Y-%m-%dT%H:%M:%S.%fZ")
+        except ValueError:
+            try:
+                # Essayer le format 'YYYY-MM-DD' si le premier essai échoue
+                registration_date = datetime.strptime(registration_date_str, "%Y-%m-%d")
+            except ValueError as e:
+                print(f"Erreur de format de date pour l'utilisateur {user_id}: {e}")
+                return
+        
+        # Obtenir la date actuelle
+        today = datetime.utcnow()
+        
+        # Vérifier si l'utilisateur s'est enregistré dans les 10 derniers jours
+        if today - registration_date <= timedelta(days=10):
+            # Convertir la date d'inscription en format 'YYYY-MM-DD' pour fetch_initial_shares
+            registration_date_str_for_query = registration_date.strftime("%Y-%m-%d")
+            
+            # Fetch shares made within the first 7 days of registration
+            shares = fetch_initial_shares(user_id, registration_date_str_for_query, 1500)
+            print(f'Registration date of user: {user_id} is: {registration_date} and number of shares is: {len(shares)}')
+            
+            # Compter le nombre de partages
+            nis = len(shares)
+            
+            # Insérer l'indicateur NIS
+            insert_indicator(user_id, "CAT1-Adoption_Integration", "Number of Initial Shares", "NIS", nis)
+        else:
+            print(f"L'utilisateur {user_id} ne s'est pas enregistré dans les 10 derniers jours.")
+
+
+        
+# --------------------------------------------
+# Utilisation des Fonctionnalités ------------
+# --------------------------------------------
+
+def calculate_suf(user_id):
+    """
+    Calcul du Score d'Utilisation des Fonctionnalités (SUF) pour chaque catégorie : Création, Interaction, Exploration.
+    """
+
+    # Définir les poids des interactions
+    weights = {
+        'creation': 3,
+        'interaction': 2,
+        'exploration': 1
+    }
+
+    # Mappage des fonctionnalités par catégorie
+    func_categories = {
+        'creation': ['m:addPost', 'm:editPost', 'm:startTour','m:editTour', 'm:addMessage'],
+        'interaction': ['m:movePost', 'm:editTour', 'm:moveTour', 'm:addMessage', 'm:deletePost', 'm:addEmoji', "m:addFavorite"],
+        'exploration': ['m:addressSearch', 'm:Filter', 'm:deleteMessage', 'm:openPost', "m:addressSearch" , "m:selectSearchResult", "m:readNotification", "m:doNotificationAction"]
+    }
+
+    # Obtenir toutes les actions de l'utilisateur à partir de Elasticsearch
+    es = get_elasticsearch_client()
+    if es is None:
+        print("Elasticsearch client is not disponible.")
+        return
+
+    # Fonction pour récupérer les actions par catégorie en utilisant l'API de défilement
+    def get_actions_by_category(category, funcs):
+        query = {
+            "query": {
+                "bool": {
+                    "must": [
+                        {"term": {"userId": user_id}}
+                    ],
+                    "filter": {
+                        "terms": {
+                            "type": funcs
+                        }
+                    }
+                }
+            },
+            "size": 10000
+        }
+        actions = []
+        response = es.search(index="mobiles", body=query, scroll='2m')
+        scroll_id = response['_scroll_id']
+        actions.extend(response['hits']['hits'])
+
+        while len(response['hits']['hits']):
+            response = es.scroll(scroll_id=scroll_id, scroll='2m')
+            scroll_id = response['_scroll_id']
+            actions.extend(response['hits']['hits'])
+
+        return actions
+
+    # Initialiser les scores pour chaque catégorie
+    usage_scores = {
+        'creation': 0,
+        'interaction': 0,
+        'exploration': 0
+    }
+
+    # Calculer le nombre d'actions par catégorie
+    count_actions = {
+        'creation': 0,
+        'interaction': 0,
+        'exploration': 0
+    }
+
+    # Récupérer et traiter les actions pour chaque catégorie
+    for category, funcs in func_categories.items():
+        actions = get_actions_by_category(category, funcs)
+        for action in actions:
+            action_type = action['_source']['type']
+            # print(f"Categorizing {action_type} as {category}")  # Impression pour vérifier la catégorisation
+            usage_scores[category] += 1  # Score sans pondération
+            count_actions[category] += 1  # Compteur d'actions
+
+    # Calculer le total des scores sans pondération
+    total_score_unweighted = sum(usage_scores.values())
+
+    # Calculer le SUF pour chaque catégorie en normalisant les scores sans pondération pour que la somme soit égale à 1
+    sufs_unweighted = {}
+    for category, score in usage_scores.items():
+        if total_score_unweighted > 0:
+            sufs_unweighted[category] = score / total_score_unweighted
+        else:
+            sufs_unweighted[category] = 0
+
+    # Calculer le score SUF global de manière pondérée
+    total_weighted_score = sum(weights[category] * sufs_unweighted[category] for category in weights)
+    suf_global = total_weighted_score / sum(weights.values()) if sum(weights.values()) > 0 else 0
+
+    # Insérer les indicateurs SUF dans la base de données
+    for category, suf in sufs_unweighted.items():
+        insert_indicator(user_id, "CAT1-Adoption_Integration", f"Score Utilisation Fonction - {category.capitalize()}", f"SUF_{category.capitalize()}", suf)
+    insert_indicator(user_id, "CAT1-Adoption_Integration", "Score Utilisation Fonction - Global", "SUF_Global", suf_global)
+
+    print(f"SUF scores: {sufs_unweighted}, SUF Global: {suf_global}")  # Impression pour vérifier les scores SUF
+    return sufs_unweighted, suf_global
+
+
+# --------------------------------------------
+# Stratégie 2 : Fonctionnalités clés – Création et partage
+# --------------------------------------------
+def calculate_nca(user_id):
+    """
+    Calcul du NCA (Number of Created Annotations) pour un utilisateur donné.
+    """
+    annotations = fetch_user_annotations(user_id)
+    # Compter le nombre d'annotations créées
+    nca = len(annotations)
+    insert_indicator(user_id, "CAT1-Adoption_Integration", "Number of Created Annotations", "NCA", nca)
+    
+
+def calculate_ncr(user_id):
+    """
+    Calcul du NCR (Number of Created Routes) pour un utilisateur donné.
+    """
+    routes = fetch_user_tours(user_id)
+    ncr = len(routes)
+    # Insérer l'indicateur NCR
+    insert_indicator(user_id, "CAT1-Adoption_Integration", "Number of Created Routes", "NCR", ncr)
+
+
+
+# -----------------------------------------------------------
+# Stratégie 3 : Fonctionnalités clés – Exploration de Contenu
+# -----------------------------------------------------------
+def calculate_ndl(user_id):
+    """
+    Calcul du NDL (Number of Discovered Locations) pour un utilisateur donné.
+    """
+    locations = fetch_discovered_locations(user_id)
+    ndl = len(locations)
+    insert_indicator(user_id, "CAT1-Adoption_Integration", "Number of Discovered Locations", "NDL", ndl)
+    
+
+def calculate_ncoa(user_id):
+    """
+    Calcul du NCoA (Number of Consulted Annotations) pour un utilisateur donné.
+    """
+
+    # Obtenir la date d'enregistrement de l'utilisateur
+    consulted_annotations = fetch_user_viewed_annotations(user_id)
+    ncoa = len(consulted_annotations)
+    insert_indicator(user_id, "CAT1-Adoption_Integration", "Number of Consulted Annotations", "NCoA", ncoa)
+    
+
+# -----------------------------------------------------------
+# Stratégie 4 : Fonctionnalités clés – Interaction
+# -----------------------------------------------------------
+def calculate_nc(user_id):
+    """
+    Calcul du Nombre de Commentaires (NC) pour un utilisateur donné.
+
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les actions sont considérées.
+    :return: NC (Nombre de Commentaires).
+    """
+    # Récupérer le nombre de commentaires depuis la date spécifiée
+    C = fetch_actions_count(user_id, 'm:addMessage')
+
+    # Insérer le nombre de commentaires dans la base de données
+    insert_indicator(
+        user_id,
+        "CAT1-Adoption_Integration",
+        "Number of Comments",
+        "NC",  # Indicateur Nombre de Commentaires
+        value=C
+    )
+
+def calculate_nr(user_id):
+    """
+    Calcul du Nombre de Réactions (NR) pour un utilisateur donné.
+
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les actions sont considérées.
+    :return: NR (Nombre de Réactions).
+    """
+    # Récupérer le nombre de réactions depuis la date spécifiée
+    R = fetch_actions_count(user_id, 'm:addEmoji')
+
+    # Insérer le nombre de réactions dans la base de données
+    insert_indicator(
+        user_id,
+        "CAT1-Adoption_Integration",
+        "Number of Reactions",
+        "NR",  # Indicateur Nombre de Réactions
+        value=R
+    )
+
diff --git a/rs/modules/metrics/content_quality_m.py b/rs/modules/metrics/content_quality_m.py
new file mode 100644
index 0000000000000000000000000000000000000000..134ea66951406b8bc4deb7625e2c597b11b3e96c
--- /dev/null
+++ b/rs/modules/metrics/content_quality_m.py
@@ -0,0 +1,448 @@
+import ast
+import pandas as pd
+import re
+import seaborn as sns
+import matplotlib.pyplot as plt
+from elasticsearch import Elasticsearch
+from elasticsearch.helpers import scan
+from sklearn.model_selection import train_test_split
+from sklearn.linear_model import LinearRegression
+from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error
+from sklearn.compose import ColumnTransformer
+from sklearn.preprocessing import OneHotEncoder, StandardScaler, MultiLabelBinarizer
+from sklearn.pipeline import Pipeline
+from sklearn.ensemble import RandomForestRegressor
+from sklearn.cluster import KMeans
+from sklearn.decomposition import PCA
+import nltk
+from nltk.corpus import stopwords
+from nltk.tokenize import word_tokenize
+from sklearn.feature_extraction.text import CountVectorizer
+from pandas import json_normalize
+from scipy import stats
+from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float, JSON, MetaData, Table, select, inspect, func
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.types import Float
+from sqlalchemy.orm.exc import NoResultFound
+from datetime import datetime
+import numpy as np
+from shapely.geometry import Point, MultiPoint, Polygon
+from sklearn.cluster import DBSCAN
+import geopandas as gpd
+import math
+import warnings
+import json
+import logging
+from sqlalchemy.orm import sessionmaker, scoped_session
+from sqlalchemy.exc import SQLAlchemyError
+import warnings
+
+from modules.db_operations import fetch_annotations, fetch_user_annotations, insert_indicator
+
+
+nltk.download('stopwords')
+nltk.download('punkt')
+
+stop_words = set(stopwords.words('french'))
+
+def clean_text(text):
+    word_tokens = word_tokenize(text)
+    
+    filtered_text = [word for word in word_tokens if word.casefold() not in stop_words]
+    
+    return " ".join(filtered_text)
+
+def calculate_MT_for_user(user_df):
+    n = len(user_df)  
+    sum_VM = user_df['volume_lexical'].sum()  
+    sum_DM = user_df['diversity_lexical'].sum()  
+
+    MT = (sum_VM + sum_DM) / (2 * n)
+    return MT
+
+def calculate_user_TauxSensible(user_df, weight):
+    try:
+        taux_annotations_sensibles = user_df['TauxAnnotationsSensibles'].astype(float).fillna(0)
+        taux_icones_sensibles = user_df['TauxIconesSensibles'].astype(float).fillna(0)
+
+        user_df['TauxSensible'] = weight * taux_annotations_sensibles + (1 - weight) * taux_icones_sensibles
+
+        return user_df
+    except (ValueError, TypeError):
+        return user_df.fillna(0)
+
+def calculate_ExpressionGraphique(row):
+    ExpressionGraphique = row['ExpressionGraphique'].sum() / row['ExpressionGraphique'].count() if row['ExpressionGraphique'].count() > 0 else 0
+    return ExpressionGraphique
+
+# Fonction Haversine pour calculer la distance entre deux points
+def haversine_distance(lat1, lon1, lat2, lon2):
+    try:
+        R = 6371  # Rayon moyen de la Terre en kilomètres
+        lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
+
+        dlat = lat2 - lat1
+        dlon = lon2 - lon1
+        a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
+        c = 2 * np.arcsin(np.sqrt(a))
+        distance = R * c
+
+        return distance
+    except (ValueError, ZeroDivisionError):
+        return np.nan
+
+
+def calculate_convex_hull_area(row):
+    user_points = MultiPoint(list(zip(row['position.lon_radians'], row['position.lat_radians'])))
+    
+    convex_hull_area = user_points.convex_hull.area
+    
+    return convex_hull_area
+
+
+def create_geospatial_profile(user_data):
+    
+    total_geolocated_annotations = user_data['position.lon'].count() 
+
+    user_data_crs = {'init': 'epsg:4326'}  # Utilisez l'EPSG correspondant à votre système de coordonnées
+    
+
+    user_data['geometry'] = [Point(lon, lat) for lon, lat in zip(user_data['position.lon'], user_data['position.lat'])]
+    user_data = gpd.GeoDataFrame(user_data, geometry='geometry', crs=user_data_crs)
+
+    distances = [haversine_distance(lat1, lon1, lat2, lon2) for (lat1, lon1), (lat2, lon2) in zip(zip(user_data['position.lat'], user_data['position.lon']), zip(user_data.shift(-1)['position.lat'], user_data.shift(-1)['position.lon']))]
+
+    user_data['distances'] = distances
+
+    avg_distance_between_annotations = user_data['distances'].mean()
+
+    avg_distance_between_annotations = avg_distance_between_annotations if not np.isnan(avg_distance_between_annotations) else 0
+
+
+    distances_series = pd.Series(distances)
+    distances_series = distances_series.dropna()
+
+
+    # Clustering des annotations en utilisant DBSCAN
+    coords = user_data[['position.lon', 'position.lat']].values
+    dbscan = DBSCAN(eps=0.5, min_samples=2)
+    user_data['cluster'] = dbscan.fit_predict(coords)
+
+    # Nombre d'annotations dans chaque cluster
+    cluster_counts = user_data['cluster'].value_counts()
+
+    # Distance totale parcourue par l'utilisateur
+    total_distance_traveled = sum(distances_series)
+
+    # Diversité géographique (nombre de clusters différents)
+    geographic_diversity = cluster_counts.shape[0]
+
+    # Create the geospatial profile dictionary
+    geospatial_profile = {
+        'total_geolocated_annotations': total_geolocated_annotations,
+        'avg_distance_between_annotations': avg_distance_between_annotations,
+        'cluster_counts': cluster_counts.to_dict(),
+        'total_distance_traveled': total_distance_traveled,
+        'geographic_diversity': geographic_diversity
+    }
+
+    return geospatial_profile
+
+
+def old_calculate_quality_indicators(user):
+    annotations = fetch_user_annotations(user)      
+    df = pd.DataFrame(annotations )
+
+    print(df)
+
+
+    # Suppression des colonnes non désirées
+    columns_to_exclude = ['@id', 'type', 'id', 'dateDisplay', 'username', 'end', '@version', 'tour_id', '@timestamp']
+    columns_to_exclude = [col for col in columns_to_exclude if col in annotations.columns]
+    df = annotations.drop(columns=columns_to_exclude, axis=1)
+
+    # Création d'une colonne 'messages' à partir de 'comment'
+    if 'comment' in df.columns:
+        df['messages'] = df['comment']
+
+    # Réinitialisation de l'index
+    df.reset_index(drop=True, inplace=True)
+
+    # Création de colonnes supplémentaires
+    if 'messages' in df.columns:
+        df['message_length'] = df['messages'].apply(len)
+    if 'image_id' in df.columns:
+        df['graphical'] = df['image_id'].apply(lambda x: 0 if pd.isnull(x) else 1)
+        df['image_id'] = df.apply(lambda row: row['image_id'] if pd.notnull(row['image_id']) or row['graphical'] == 0 else 'unknown_image', axis=1)
+    else:
+        df['graphical'] = 0
+
+    # Définir la fonction clean_text si ce n'est pas déjà fait
+    def clean_text(text):
+        # Exemple de fonction de nettoyage. Remplacez par la vôtre.
+        return text.lower().strip()
+
+    if 'messages' in df.columns:
+        df['clean_message'] = df['messages'].apply(clean_text)
+        df['clean_message_length'] = df['clean_message'].apply(len)
+
+
+    df_icons = pd.DataFrame()
+    if 'tags' in df.columns:
+        df['tags'] = df['tags'].apply(lambda x: x if isinstance(x, list) else [])
+        df_tags = df['tags'].explode().str.get_dummies().rename(lambda x: f'tag-{x}', axis=1)
+        df = pd.concat([df, df_tags], axis=1)
+
+
+    if 'icons' in df.columns:
+        # Split 'icons' into a list of icons
+        df['icons'] = df['icons'].apply(lambda x: x.split(',') if isinstance(x, str) else [])
+        # Create DataFrame where each icon gets its own column
+        df_icons = df['icons'].apply(lambda x: pd.Series([1] * len(x), index=x)).fillna(0)
+        # Ensure that df_icons is a DataFrame
+        df_icons = pd.DataFrame(df_icons.tolist()).fillna(0).astype(int)
+        df_icons.columns = ['icon-' + str(col) for col in df_icons.columns]
+        df_icons = df_icons.astype(int)
+
+    # Concatenate the original DataFrame with the new columns
+    df = pd.concat([df, df_tags, df_icons], axis=1)
+    
+    
+    
+    # Transformation des colonnes catégorielles
+    df_place = pd.get_dummies(df['placeType'], prefix='place') if 'placeType' in df.columns else pd.DataFrame()
+    df_timing = pd.get_dummies(df['timing'], prefix='timing') if 'timing' in df.columns else pd.DataFrame()
+    df_emoticon = pd.get_dummies(df['emoticon'], prefix='emoticon') if 'emoticon' in df.columns else pd.DataFrame()
+
+    # Concaténation des DataFrames
+    df = pd.concat([df, df_place, df_timing, df_emoticon, df_tags, df_icons], axis=1)
+
+    # Affichage complet des colonnes
+    pd.set_option('display.max_columns', 1000)
+
+    # Création d'une copie pour les traces
+    df_traces = df.copy()
+    df_traces['user'] = df_traces['user'].astype(int)
+
+    ## Masse Textuelle
+    data_list = []
+
+    current_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+    mt_value = calculate_MT_for_user(df_traces)
+    data_list.append({'user': user, 'type': 'MasseTextuelle', 'value': mt_value, 'date': current_date})
+
+    df_indicator = pd.DataFrame(data_list, columns=['user', 'type', 'value', 'date'])
+    
+    
+    df_traces['TauxAnnotationsSensibles'] = df_traces['memory_icons_count'] / df_traces.groupby('user')['memory_icons_count'].transform('sum')
+    df_traces['TauxIconesSensibles'] = df_traces['memory_icons_count'] / df_traces.groupby('user')['memory_icons_count'].transform('sum')
+
+    weight = 0.5
+
+    
+    current_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+
+    user_df_with_taux_sensible = calculate_user_TauxSensible(df_traces, weight)
+    taux_sensible_mean = user_df_with_taux_sensible['TauxSensible'].mean()
+        
+
+    df_indicator = pd.concat([df_indicator, pd.DataFrame({
+            'user': [user],
+            'type': ['TauxSensible'],
+            'value': [taux_sensible_mean],
+            'date': [current_date]
+        })], ignore_index=True)
+
+
+    ## Diversification iconographique
+    df_traces = df.copy()
+    df_traces['user'] = df_traces['user'].astype(int)
+
+    alpha = 0.5
+    beta = 0.5 
+
+    
+    current_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+    NombreMoyenIcones = df_traces['total_icons_count'].mean()
+    NombreMoyenTypesIcones = df_traces['diversity_types_icons_used'].mean()
+
+    icon_types = ['social', 'mission', 'memory', 'activity', 'senses', 'environment']
+    icon_types_usage = df_traces[[type + '_icons_count' for type in icon_types]].sum()
+
+        # Handle division by zero and replace NaNs with zeros
+    total_icons_used = df_traces['total_icons_count'].sum()
+    if total_icons_used != 0:            
+            icon_types_proportion = icon_types_usage / total_icons_used
+            # Créer un dictionnaire de mapping pour les noms de colonnes
+            column_mapping = {col: re.sub(r'^stats\.icons_stats\.|\_icons_count$', '', col) for col in icon_types_proportion.index}
+
+            # Renommer les colonnes dans icon_types_proportion sans modifier le type
+            icon_types_proportion = icon_types_proportion.rename(index=column_mapping)
+            # print(icon_types_proportion)
+    else:
+            icon_types_proportion = pd.Series([0] * len(icon_types), index=icon_types)
+            
+
+    icon_types_proportion = icon_types_proportion.fillna(0)  # Replace NaNs with zeros
+
+    for icon_type, proportion in icon_types_proportion.items():
+            df_indicator = pd.concat([df_indicator, pd.DataFrame({ 'user': [user], 'type': [f'Taux{icon_type.capitalize()}'], 'value': [float(proportion)], 'date': [current_date]})], ignore_index=True)
+
+    diversification_iconographique_value = alpha * NombreMoyenIcones + beta * NombreMoyenTypesIcones
+
+        # Handle NaN values and replace them with zeros
+    diversification_iconographique_value = 0 if np.isnan(diversification_iconographique_value) else diversification_iconographique_value
+        
+    df_indicator = pd.concat([df_indicator, pd.DataFrame({ 'user': [user], 'type': ['DiversificationIconographique'], 'value': [diversification_iconographique_value], 'date': [current_date]})], ignore_index=True)
+
+        # Include zero values for icon usage types
+    if total_icons_used == 0:
+            for icon_type in icon_types:
+                df_indicator = pd.concat([df_indicator, pd.DataFrame({ 'user': [user], 'type': [f'Taux{icon_type.capitalize()}'], 'value': [0], 'date': [current_date]})], ignore_index=True)
+
+
+    
+    
+
+    ## Expression graphique
+    df_traces = df.copy()
+    df_traces['user'] = df_traces['user'].astype(int)
+
+    df_traces['ExpressionGraphique'] = df_traces['graphical'].astype(int)
+
+    current_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+    expression_graphique_value = calculate_ExpressionGraphique(df_traces)
+    df_indicator = pd.concat([df_indicator, pd.DataFrame({ 'user': [user], 'type': ['ExpressionGraphique'], 'value': [expression_graphique_value], 'date': [current_date]})], ignore_index=True)
+
+    
+
+    for _, indicator_row in df_indicator.iterrows():
+            # Convertir la ligne en dictionnaire (non nécessaire ici mais utilisé pour la démonstration)
+            indicator_dict = indicator_row.to_dict()
+
+            # Utiliser la notation de clé pour accéder aux valeurs des colonnes
+            if(not math.isnan(indicator_row['value'])):
+                insert_indicator(user, 
+                            "Qualité du contenu", 
+                            indicator_row['type'],  # Accéder à la colonne 'type' avec la clé
+                            indicator_row['type'],  # Accéder à la colonne 'type' avec la clé
+                            indicator_row['value'])  # Accéder à la colonne 'value' avec la clé
+    
+    print('That\'s all folks')
+
+
+
+# NEW VERSION
+def compute_annotation_score(annotation):
+    """
+    Calcule le score de qualité d'une annotation en fonction de plusieurs critères pondérés.
+
+    Paramètres:
+    - annotation (DataFrame): Une annotation individuelle sous forme de DataFrame.
+
+    Retourne:
+    - float: Le score de qualité de l'annotation.
+    """
+    score = 0
+    total_weight = 0
+
+    # Critères de qualité avec leurs poids respectifs
+    criteria_weights = {
+        'message_length': 0.3,  # Longueur du message
+        'graphical': 0.2,       # Utilisation d'images (1 ou 0)
+        'num_tags': 0.2,        # Nombre de tags
+        'num_icons': 0.2,       # Nombre d'icônes
+        'diversity_of_icons': 0.1  # Diversité des icônes (pourcentage ou ratio)
+    }
+
+    # Calculer le score pour chaque critère
+    for criterion, weight in criteria_weights.items():
+        if criterion == 'message_length':
+            score += min(len(annotation['messages']) / 100, 1) * weight  # Normalisation de la longueur
+        elif criterion == 'graphical':
+            score += annotation['graphical'] * weight
+        elif criterion == 'num_tags':
+            score += min(len(annotation['tags']), 5) / 5 * weight  # Normalisation sur 5 tags max
+        elif criterion == 'num_icons':
+            score += min(annotation['total_icons_count'], 10) / 10 * weight  # Normalisation sur 10 icônes max
+        elif criterion == 'diversity_of_icons':
+            score += annotation['diversity_types_icons_used'] * weight  # Supposé normalisé à 1
+
+        total_weight += weight
+
+    # Retourner le score normalisé
+    return score / total_weight if total_weight > 0 else 0
+
+
+def calculate_sqpa(user_id):
+    annotations = fetch_user_annotations(user_id)
+    total_score = sum(compute_annotation_score(row) for index, row in annotations.iterrows())
+    sqpa_value = total_score / len(annotations) if not annotations.empty else 0
+    insert_indicator(user_id, "CAT3-Quality", "Qualité du contenu", "SQPA", sqpa_value)
+
+def calculate_spa(user_id):
+    annotations = fetch_user_annotations(user_id)
+    low_quality_annotations = [row for index, row in annotations.iterrows() if compute_annotation_score(row) < 3]  
+    spa_value = len(low_quality_annotations) / len(annotations) if not annotations.empty else 0
+    insert_indicator(user_id, "CAT3-Quality", "Qualité du contenu",  "SPA", spa_value)
+
+def calculate_volume_lexical(user_id):
+    annotations = fetch_user_annotations(user_id)
+    total_volume = sum(len(row.get('messages', '').split()) for index, row in annotations.iterrows())
+    vl_value = total_volume / len(annotations) if not annotations.empty else 0
+    insert_indicator(user_id, "CAT3-Quality", "Expression Textuelle", "VL", vl_value)
+
+def calculate_diversite_lexicale(user_id):
+    annotations = fetch_user_annotations(user_id)
+    unique_words = set()
+    for index, row in annotations.iterrows():
+        unique_words.update(row.get('messages', '').split())
+    dl_value = len(unique_words)
+    insert_indicator(user_id, "CAT3-Quality", "Expression Textuelle",  "DL", dl_value)
+
+def calculate_idi(user_id):
+    annotations = fetch_user_annotations(user_id)
+    icons = []
+    for index, row in annotations.iterrows():
+        icons.extend(row.get('icons', []))
+    unique_icons = len(set(icons))
+    idi_value = unique_icons / len(annotations) if not annotations.empty else 0
+    insert_indicator(user_id, "CAT3-Quality", "Enrichissement symbolique",  "IDI", idi_value)
+
+
+def calculate_ifi(user_id):
+    annotations = fetch_user_annotations(user_id)
+    total_icons = sum(len(row.get('icons', [])) for index, row in annotations.iterrows())
+    ifi_value = total_icons / len(annotations) if not annotations.empty else 0
+    insert_indicator(user_id, "CAT3-Quality", "Enrichissement symbolique","IFI", ifi_value)
+
+def calculate_irt(user_id):
+    annotations = fetch_user_annotations(user_id)
+    total_tags = sum(len(row.get('tags', [])) for index, row in annotations.iterrows())
+    irt_value = total_tags / len(annotations) if not annotations.empty else 0
+    insert_indicator(user_id, "CAT3-Quality", "Enrichissement symbolique", "IRT", irt_value)
+
+def calculate_ue(user_id):
+    annotations = fetch_user_annotations(user_id)
+    emoji_count = sum('emoji' in row.get('messages', '') for index, row in annotations.iterrows())
+    ue_value = emoji_count / len(annotations) if not annotations.empty else 0
+    insert_indicator(user_id, "CAT3-Quality", "Enrichissement symbolique",  "UE", ue_value)
+
+def calculate_ti(user_id):
+    annotations = fetch_user_annotations(user_id)
+    image_count = sum(row.get('image_id') is not None for index, row in annotations.iterrows())
+    ti_value = image_count / len(annotations) if not annotations.empty else 0
+    insert_indicator(user_id, "CAT3-Quality", "Expression Graphique", "TI", ti_value)
+
+
+def calculate_quality_indicators(user_id):
+    calculate_sqpa(user_id)
+    calculate_spa(user_id)
+    calculate_volume_lexical(user_id)
+    calculate_diversite_lexicale(user_id)
+    calculate_idi(user_id)
+    calculate_ifi(user_id)
+    calculate_irt(user_id)
+    calculate_ue(user_id)
+    calculate_ti(user_id)
diff --git a/rs/modules/metrics/engagement_reengagement_m.py b/rs/modules/metrics/engagement_reengagement_m.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a1574ecb8c86dace5cf3497557d653c2993b49f
--- /dev/null
+++ b/rs/modules/metrics/engagement_reengagement_m.py
@@ -0,0 +1,1255 @@
+from collections import defaultdict
+from datetime import datetime, timedelta
+import json
+import math
+from sqlalchemy.exc import SQLAlchemyError
+
+import pandas as pd
+from modules.db_operations import fetch_annotations, fetch_annotations_by_id, fetch_annotations_by_user, fetch_comments, fetch_notifications, fetch_indicators, fetch_recommendations, fetch_tours, fetch_user_tours, fetch_user_annotations, fetch_user_profile, fetch_users, get_indicator_value, insert_indicator
+from modules.es_operations import calculate_distance, fetch_actions_count, fetch_address_searches, fetch_annotation_popularity, fetch_interaction_count_for_content, fetch_interactions_by_content, fetch_logs_for_user, fetch_new_interactions, fetch_opened_posts, fetch_user_actions, fetch_user_filters, fetch_user_locations, fetch_user_logs, fetch_user_logs_by_type, fetch_user_logs_with_time, fetch_user_viewed_annotations, fetch_user_viewed_tours, reconstruct_sessions
+from geopy.distance import geodesic
+
+DEFAULT_SINCE_DATE = "0000-01-01"
+# --------------------------------------------
+# Catégorie 2 : Promotion d'un Engagement Durable et Ré-engagement
+# --------------------------------------------
+# Stratégie 1 : Maintien de l'Engagement Basé sur le Profil d’Annotation
+# --------------------------------------------
+
+def calculate_annotation_creation_weight(user_id):
+    """
+    Calcule le poids de création d'annotations (𝒘_𝑷𝑪A) et l'insère dans la base de données.
+    """
+    profile = fetch_user_profile(user_id)
+    if profile.empty:
+        return
+
+    created_annotations = profile['pca_items_count'].values[0]
+    print("profile: ", profile)
+    print("profile['pca_items_count'].values[0]: ", profile['pca_items_count'].values[0], "et profile['pcoa_items_count'].values[0]: ", profile['pcoa_items_count'].values[0])
+    total_interactions = created_annotations + profile['pcoa_items_count'].values[0]
+
+    if total_interactions == 0:
+        value = 0
+    else:
+        value = created_annotations / total_interactions
+
+    insert_indicator(user_id, "CAT2-Engagement", "Weight of Annotation Creation Profile", "WPCA", value)
+
+
+def calculate_annotation_consultation_weight(user_id):
+    """
+    Calcule le poids de consultation d'annotations (𝒘_𝑷𝑪𝑶𝑨) et l'insère dans la base de données.
+    """
+    profile = fetch_user_profile(user_id)
+    if profile.empty:
+        return
+
+    consulted_annotations = profile['pcoa_items_count'].values[0]
+    total_interactions = profile['pca_items_count'].values[0] + consulted_annotations
+
+    if total_interactions == 0:
+        value = 0
+    else:
+        value = consulted_annotations / total_interactions
+
+    insert_indicator(user_id, "CAT2-Engagement", "Weight of Annotation COnsultation Profile", "WPCOA", value)
+
+
+# --------------------------------------------
+# Stratégie 2 : Maintien de l'Engagement Basé sur la Proximité des Lieux Annotés
+# --------------------------------------------
+
+def calculate_pla(user_id, user_locations):
+    """
+    Calcul du PLA (Proximité des lieux annotés) pour un utilisateur donné.
+    """
+    print('Computing PLA for user: ', user_id)
+    # Définir les filtres pour exclure les annotations de l'utilisateur actuel
+    field_filters_to_exclude = {
+        'user': [user_id]
+    }
+
+    # Récupérer les annotations en excluant celles de l'utilisateur actuel
+    annotations = fetch_annotations(field_filters_to_exclude=field_filters_to_exclude)
+    
+    if annotations.empty:
+        pla = 0
+    else:
+        distances = []
+
+        for _, annotation in annotations.iterrows():
+            coords = annotation['coords']
+            coords_dict = json.loads(coords)
+            annotation_location = {
+                "lat": coords_dict['lat'],
+                "lon": coords_dict['lon']
+            }
+            dist = calculate_distance(user_locations, annotation_location)
+            if dist is not None:
+                distances.append(dist)
+
+        # Calculer la distance moyenne
+        pla = sum(distances) / len(distances) if distances else 0
+    
+    insert_indicator(user_id, "CAT2-Engagement", "Proximié des Lieux Annotés", "PLA", pla)
+
+# --------------------------------------------
+# Stratégie 3 : suggestion de parcours basée profil de parcours
+# --------------------------------------------
+
+
+def calculate_path_creation_weight(user_id):
+    """
+    Calcule le poids de création de parcours (𝒘_𝑷𝑪𝑷) et l'insère dans la base de données.
+    """
+    profile = fetch_user_profile(user_id)
+    
+    if profile.empty:
+        return
+    
+    # Extraire les valeurs avec une vérification explicite pour gérer les None
+    created_paths = profile['pcp_items_count'].values[0]
+    pcop_items_count = profile['pcop_items_count'].values[0]
+
+    # Vérifier que les valeurs ne sont pas None, sinon les remplacer par 0
+    created_paths = created_paths if created_paths is not None else 0
+    pcop_items_count = pcop_items_count if pcop_items_count is not None else 0
+    
+    total_interactions = created_paths + pcop_items_count
+
+    
+
+    if total_interactions == 0:
+        value = 0
+    else:
+        value = created_paths / total_interactions
+
+    insert_indicator(user_id, "CAT2-Engagement", "Weight of Tour Creation Profile", "WPCP", value)
+
+
+def calculate_path_consultation_weight(user_id):
+    """
+    Calcule le poids d'intérêts de parcours (𝒘_𝑷𝑪𝒐𝑷) et l'insère dans la base de données.
+    """
+    profile = fetch_user_profile(user_id)
+    
+    if profile.empty:
+        return
+    
+    # Extraire les valeurs, avec vérification pour éviter les None
+    consulted_paths = profile['pcop_items_count'].values[0]
+    created_paths = profile['pcp_items_count'].values[0]
+
+    # Vérifier que les valeurs ne sont pas None, sinon les remplacer par 0
+    consulted_paths = consulted_paths if consulted_paths is not None else 0
+    created_paths = created_paths if created_paths is not None else 0
+
+    total_interactions = created_paths + consulted_paths
+
+    if total_interactions == 0:
+        value = 0
+    else:
+        value = consulted_paths / total_interactions
+
+    # Insérer l'indicateur calculé
+    insert_indicator(user_id, "CAT2-Engagement", "Weight of Tour COnsultation Profile", "WPCOP", value)
+
+
+
+# --------------------------------------------
+# Stratégie 4 : Réengagement à travers les Interactions Utilisateur
+# --------------------------------------------
+def calculate_id(user_id, period_days=30):
+    """
+    Calcul de l'Indicateur de Désengagement (ID) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur
+    :param period_days: Nombre de jours pour la période d'évaluation
+    :return: Indicateur de Désengagement (ID)
+    """
+    end_date = datetime.now()
+    start_date = end_date - timedelta(days=period_days)
+    
+    # Types de logs avec poids associés
+    log_weights = {
+        'm:addPost': 5,
+        'm:editPost': 4,
+        'm:openPost': 3,
+        'm:openMessage': 2,
+        'm:startTour': 6,
+        'm:addMessage': 4,
+        'm:addEmoji': 1,
+        'm:movePost': 2,
+        'm:editTour': 5,
+        'm:addFavorite': 3,
+        'm:moveTour': 4,
+        'm:addressSearch': 3,
+        'm:Filter': 2,
+        'm:deletePost': 1,
+        'm:deleteMessage': 1
+    }
+    
+    # Récupérer les logs d'activité pour chaque type
+    total_activity = 0
+    for log_type, weight in log_weights.items():
+        logs = fetch_user_logs_by_type(user_id, start_date, end_date, log_type)
+        total_activity += len(logs) * weight
+    
+    # Calculer l'ID comme la moyenne des activités pondérées
+    num_logs = len(log_weights)  # Nombre de types de logs considérés
+    ID = total_activity / period_days if period_days > 0 else 0
+    
+    # Insertion de l'indicateur ID dans la base de données
+    insert_indicator(user_id, "CAT2-Engagement", "Indice Désengagement", "ID", ID)
+    
+    return ID
+
+# --------------------------------------------
+# Stratégie 5 : Réengagement Basé sur l’Historique des Activités
+# --------------------------------------------
+def calculate_sea(user_id, indicators_df):
+    """
+    Calcule le Score d'Engagement d'Annotations (SEA) basé sur l'évolution de la création et consultation d'annotations.
+    """
+    profile = fetch_user_profile(user_id)
+    if profile.empty:
+        return
+
+    # Pondérations
+    alpha = 0.5
+    beta = 0.5
+
+    # Récupérer les données actuelles
+    current_w_pca = profile['pca_items_count'].values[0] if 'pca_items_count' in profile.columns else 0
+    current_w_pcoa = profile['pcoa_items_count'].values[0] if 'pcoa_items_count' in profile.columns else 0
+
+    # Récupérer les données historiques pour PCA et PCOA
+    past_w_pca = get_indicator_value(indicators_df, 'pca_items_count', offset=1)  # Offset pour 30 jours en arrière
+    past_w_pcoa = get_indicator_value(indicators_df, 'pcoa_items_count', offset=1)  # Offset pour 30 jours en arrière
+
+    # Vérifier l'existence des données historiques, sinon utiliser 0
+    past_w_pca = past_w_pca if past_w_pca is not None else 0
+    past_w_pcoa = past_w_pcoa if past_w_pcoa is not None else 0
+
+    # Calculer l'évolution
+    delta_w_pca = current_w_pca - past_w_pca
+    delta_w_pcoa = current_w_pcoa - past_w_pcoa
+
+    # Calculer le SEA
+    sea_score = (alpha * (delta_w_pca / past_w_pca if past_w_pca != 0 else 0) +
+                 beta * (delta_w_pcoa / past_w_pcoa if past_w_pcoa != 0 else 0))
+
+    # Insérer le score dans la base de données
+    insert_indicator(user_id, "CAT2-Engagement", "Score d'Engagement d'Annotations", "SEA", sea_score)
+
+
+
+def calculate_sep(user_id, indicators_df):
+    """
+    Calcule le Score d'Engagement des Parcours (SEP) basé sur l'évolution de la création et consultation de parcours.
+    """
+    profile = fetch_user_profile(user_id)
+    if profile.empty:
+        return
+
+    # Pondérations
+    gamma = 0.5
+    delta = 0.5
+
+    # Récupérer les données actuelles
+    current_w_pcp = profile['pcp_items_count'].values[0] if 'pcp_items_count' in profile.columns else 0
+    current_w_pcop = profile['pcop_items_count'].values[0] if 'pcop_items_count' in profile.columns else 0
+
+    # S'assurer que les valeurs actuelles sont des nombres (0 si None)
+    current_w_pcp = float(current_w_pcp) if current_w_pcp is not None else 0.0
+    current_w_pcop = float(current_w_pcop) if current_w_pcop is not None else 0.0
+
+    # Récupérer les données historiques pour PCP et PCOP
+    past_w_pcp = get_indicator_value(indicators_df, 'pcp_items_count', offset=1)  # Offset pour 30 jours en arrière
+    past_w_pcop = get_indicator_value(indicators_df, 'pcop_items_count', offset=1)  # Offset pour 30 jours en arrière
+
+    # Vérifier l'existence des données historiques, sinon utiliser 0
+    past_w_pcp = past_w_pcp if past_w_pcp is not None else 0.0
+    past_w_pcop = past_w_pcop if past_w_pcop is not None else 0.0
+
+    # Calculer l'évolution
+    delta_w_pcp = current_w_pcp - past_w_pcp
+    delta_w_pcop = current_w_pcop - past_w_pcop
+
+    # Calculer le SEP
+    sep_score = (gamma * (delta_w_pcp / past_w_pcp if past_w_pcp != 0 else 0) +
+                 delta * (delta_w_pcop / past_w_pcop if past_w_pcop != 0 else 0))
+
+    # Insérer le score dans la base de données
+    insert_indicator(user_id, "CAT2-Engagement", "Score d'Engagement des Parcours", "SEP", sep_score)
+
+
+# --------------------------------------------
+#  Stratégie 6 : Réengagement à Travers le Contenu de la Communauté
+# --------------------------------------------
+def calculate_sca(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule le Score de Consultation des Annotations (SCA) pour un utilisateur.
+
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les consultations sont considérées.
+    :return: SCA normalisé entre 0 et 1.
+    """
+    # Récupérer les annotations vues par l'utilisateur depuis la date spécifiée
+    viewed_annotations = fetch_user_viewed_annotations(user_id, since_date)
+    
+    # Compter le nombre d'annotations vues
+    A = len(viewed_annotations)
+
+    # Récupérer le total d'annotations disponibles
+    total_annotations = len(fetch_annotations())
+
+    # Calculer le SCA comme une proportion
+    sca = (A / total_annotations) if total_annotations > 0 else 0
+
+    # Insérer l'indicateur SCA dans la base de données
+    insert_indicator(
+        user_id,
+        "CAT2-Engagement",
+        "Score de Consultation des Annotations",
+        "SCA",  # Indicateur Score de Consultation des Annotations
+        value=sca
+    )
+    
+    return sca
+
+def calculate_scp(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule le Score de Consultation des Parcours (SCP) pour un utilisateur.
+
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les parcours sont considérés.
+    :return: SCP normalisé entre 0 et 1.
+    """
+    # Récupérer les parcours consultés par l'utilisateur depuis la date spécifiée
+    viewed_paths = fetch_user_viewed_tours(user_id, since_date) 
+    # Compter le nombre de parcours consultés
+    P = len(viewed_paths)
+
+    # Récupérer le total de parcours disponibles
+    total_paths = fetch_tours() 
+
+    # Convertir total_paths en entier si nécessaire
+    try:
+        total_paths = len(total_paths)
+    except ValueError:
+        total_paths = 0  # Si la conversion échoue, on met total_paths à 0
+
+    # Calculer le SCP comme une proportion
+    scp = (P / total_paths) if total_paths > 0 else 0
+
+    # Insérer l'indicateur SCP dans la base de données
+    insert_indicator(
+        user_id,
+        "CAT2-Engagement",
+        "Score de Consultation des Parcours",
+        "SCP",  # Indicateur Score de Consultation des Parcours
+        value=scp
+    )
+    
+    return scp
+
+
+def calculate_sis(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule le Score d'Interaction Sociale (SIS) pour un utilisateur.
+
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les interactions sont considérées.
+    :return: SIS normalisé entre 0 et 1.
+    """
+    # Récupérer le nombre de commentaires et de réactions depuis la date spécifiée
+    C = fetch_actions_count(user_id, 'm:addMessage')  # Nombre de commentaires
+    R = fetch_actions_count(user_id, 'm:addEmoji')    # Nombre de réactions
+    
+    # Total des commentaires et réactions disponibles dans la période
+    total_comments = fetch_comments()  # Total des commentaires dans la période
+    # total_reactions = fetch_total_reactions_count()  # Total des réactions dans la période
+    
+    total_interactions = total_comments # + total_reactions  # Somme des interactions possibles
+
+    # Calculer le SIS comme une proportion
+    sis = ((C + R) / len(total_interactions)) if len(total_interactions) > 0 else 0
+
+    # Insérer l'indicateur SIS dans la base de données
+    insert_indicator(
+        user_id,
+        "CAT2-Engagement",
+        "Score d'Interaction Sociale",
+        "SIS",
+        value=sis
+    )
+    
+    return sis
+
+############################################################################################
+############################################################################################
+#  OLD VERSION
+def calculate_tel(user_id):
+    """
+    Calcul du TEL (Taux d'exploration de lieux annotés) pour un utilisateur donné.
+    """
+    print('Computing TEL for user: ', user_id)
+    logs = fetch_logs_for_user(user_id)
+    if not logs:
+        tel = 0
+    else:
+        num_explorations = sum(1 for log in logs if 'open_annotation' in log['_source']['type'] or 'open_route' in log['_source']['type'])
+        num_notifications = sum(1 for log in logs if 'notification' in log['_source']['type'])
+
+        tel = num_explorations / num_notifications if num_notifications > 0 else 0
+
+    insert_indicator(user_id, "CAT2-Engagement", "Exploration Contextuelle des Lieux Annotés", "TEL", tel)
+
+
+# --------------------------------------------
+# Stratégie 2 : Annotation Contextuelle Opportune
+# --------------------------------------------
+THRESHOLD_OA = 3
+THRESHOLD_TCA = 0.2
+
+def identify_non_annotated_places(locations, annotations, threshold_distance=0.05):
+    """
+    Identifie les lieux intéressants non encore annotés.
+    
+    :param locations: Liste des emplacements visités ou recherchés par l'utilisateur (avec coordonnées 'lat' et 'lon').
+    :param annotations: Liste des annotations existantes (avec coordonnées 'lat' et 'lon').
+    :param threshold_distance: Distance maximale (en kilomètres) pour considérer qu'un lieu est déjà annoté. Par défaut, 50 mètres.
+    :return: Liste des emplacements non annotés.
+    """
+    non_annotated_places = []
+    
+    for loc in locations:
+        lat = loc.get('lat')
+        lon = loc.get('lon')
+        
+        is_annotated = False
+        for ann in annotations:
+            ann_lat = ann.get('lat')
+            ann_lon = ann.get('lon')
+            distance = calculate_distance({"lat": lat, "lon": lon}, {"lat": ann_lat, "lon": ann_lon})
+            
+            if distance <= threshold_distance:
+                is_annotated = True
+                break
+        
+        if not is_annotated:
+            non_annotated_places.append(loc)
+    
+    return non_annotated_places
+
+
+def calculate_oa(user_id, start_date=None, days=None):
+    """
+    Calcul de l'Opportunité d'Annotation (OA) pour un utilisateur donné.
+    """
+    print('Computing OA for user: ', user_id)
+    locations = fetch_user_locations(user_id, start_date, days)
+    annotations = fetch_user_annotations(user_id, start_date)
+    
+    non_annotated_places = identify_non_annotated_places(locations, annotations)
+    oa = len(non_annotated_places)
+    
+    insert_indicator(user_id, "CAT2-Engagement", "Annotation Contextuelle Opportune", "OA", oa)
+    insert_indicator(user_id, "CAT2-Engagement", "Annotation Contextuelle Opportune", "non_annotated_places", non_annotated_places)
+    
+    return non_annotated_places, oa
+
+def calculate_tca(user_id, start_date=None, days=None):
+    """
+    Calcul du Taux de Création d'Annotations (TCA) pour un utilisateur donné.
+    """
+    print('Computing TCA for user: ', user_id)
+    annotations = fetch_user_annotations(user_id, start_date)
+    locations = fetch_user_locations(user_id, start_date, days)
+    
+    if not locations or not annotations:
+        tca = 0
+    else:
+        num_annotations = len([ann for ann in annotations if ann['created']])
+        num_locations = len(locations)
+        
+        tca = num_annotations / num_locations if num_locations > 0 else 0
+    
+    insert_indicator(user_id, "CAT2-Engagement", "Annotation Contextuelle Opportune", "TCA", tca)
+    
+    return tca
+
+
+# --------------------------------------------------
+# Stratégie 3 : Découverte Contextuelle de Parcours
+# --------------------------------------------------
+def correct_coordinates(lat, lon):
+    """
+    Corrige les coordonnées pour s'assurer qu'elles sont dans les plages valides.
+    
+    :param lat: Latitude à corriger
+    :param lon: Longitude à corriger
+    :return: Tuple contenant la latitude et la longitude corrigées
+    """
+    lat = max(min(lat, 90), -90)
+    lon = max(min(lon, 180), -180)
+    return lat, lon
+
+def calculate_min_distance_to_tour(user_location, tour_coordinates):
+    """
+    Calcule la distance minimale entre la localisation de l'utilisateur et un parcours.
+
+    :param user_location: Dictionnaire contenant 'lat' et 'lon' pour la localisation de l'utilisateur.
+    :param tour_coordinates: Liste de tuples représentant les points du parcours [(lat, lon), (lat, lon), ...].
+    :return: Distance minimale en kilomètres.
+    """
+    min_distance = float('inf')
+
+
+
+    # Assurez-vous que user_location est bien un dictionnaire
+    if not isinstance(user_location, dict) or 'lat' not in user_location or 'lon' not in user_location:
+        return None
+
+    for coord in tour_coordinates:
+        point = correct_coordinates(coord[1], coord[0])  # (latitude, longitude)
+        distance = geodesic((user_location['lat'], user_location['lon']), point).kilometers
+        
+        if distance < min_distance:
+            min_distance = distance
+
+    return min_distance
+
+
+def calculate_tep(user_id):
+    """
+    Calcul du Taux d'exploration de Parcours (TEP) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur
+    :return: TEP
+    """
+    print('Computing TEP for user: ', user_id)
+    logs = fetch_logs_for_user(user_id)
+    
+    if not logs:
+        tep = 0
+    else:
+        # Comptage des explorations de parcours (supposant que chaque log pertinent est une exploration)
+        num_explorations = sum(1 for log in logs if 'tour_exploration' in log['_source']['type'])
+        
+        # Comptage des notifications envoyées
+        num_notifications = sum(1 for log in logs if 'notification' in log['_source']['type'] and 'tour_recommendation' in log['_source']['subtype'])
+        
+        tep = num_explorations / num_notifications if num_notifications > 0 else 0
+    
+    # Insertion dans la base de données 
+    insert_indicator(user_id, "CAT2-Engagement", "Découverte Contextuelle de Parcours", "TEP", tep)
+
+def calculate_pp(user_id, user_location):
+    """
+    Calcul de la Proximité des Parcours (PP) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur
+    :param user_location: Dictionnaire contenant 'lat' et 'lon' pour la localisation de l'utilisateur.
+    :return: Proximité des Parcours (PP)
+    """
+    print('Computing PP for user: ', user_id)
+    # Récupération de tous les parcours depuis la base de données (retourne un DataFrame ou une liste vide)
+    tours = fetch_tours()
+    
+    pp = 0
+    if isinstance(tours, pd.DataFrame) and not tours.empty:
+        distances = []
+        
+        for _, tour in tours.iterrows():
+            # Extraction des coordonnées du champ 'body'
+            coordinates = json.loads(tour['body'])
+            # Conversion en une liste de tuples [(lat, lon), (lat, lon), ...]
+            tour_coordinates = [(coordinates[i], coordinates[i+1]) for i in range(0, len(coordinates), 2)]
+            # Calcul de la distance minimale entre l'utilisateur et ce parcours
+            dist = calculate_min_distance_to_tour(user_location, tour_coordinates)
+            if dist:
+                distances.append(dist)
+        
+        # Calcul de la distance moyenne si des distances ont été trouvées
+        pp = sum(distances) / len(distances) if distances else 0
+    else:
+        print("Soit l'utilisateur n'a pas de parcours ou bien il y a erreur")
+    
+    # Insertion dans la base de données 
+    insert_indicator(user_id, "CAT2-Engagement", "Découverte Contextuelle de Parcours", "PP", pp)
+
+
+# --------------------------------------------
+# Stratégie 4 : Création Contextuelle de Parcours
+# --------------------------------------------
+import json
+from geopy.distance import geodesic
+
+def correct_coordinates(lat, lon):
+    """
+    Corrige les coordonnées pour s'assurer qu'elles sont dans les plages valides.
+    
+    :param lat: Latitude à corriger
+    :param lon: Longitude à corriger
+    :return: Tuple contenant la latitude et la longitude corrigées
+    """
+    lat = max(min(lat, 90), -90)
+    lon = max(min(lon, 180), -180)
+    return lat, lon
+
+def calculate_min_distance_to_tour(user_location, tour_coordinates):
+    """
+    Calcule la distance minimale entre la localisation de l'utilisateur et un parcours.
+
+    :param user_location: Dictionnaire contenant 'lat' et 'lon' pour la localisation de l'utilisateur.
+    :param tour_coordinates: Liste de tuples représentant les points du parcours [(lat, lon), (lat, lon), ...].
+    :return: Distance minimale en kilomètres.
+    """
+    min_distance = float('inf')
+    if not isinstance(user_location, dict) or 'lat' not in user_location or 'lon' not in user_location:
+        return None
+
+    for coord in tour_coordinates:
+        point = correct_coordinates(coord[1], coord[0])
+        distance = geodesic((user_location['lat'], user_location['lon']), point).kilometers
+        if distance < min_distance:
+            min_distance = distance
+
+    return min_distance
+
+def is_within_proximity(tour_coordinates, user_location, proximity_threshold_km=5):
+    """
+    Vérifie si l'une des coordonnées du tour est à proximité de la localisation de l'utilisateur.
+
+    :param tour_coordinates: Liste de tuples représentant les points du parcours [(lat, lon), (lat, lon), ...].
+    :param user_location: Dictionnaire contenant 'lat' et 'lon' pour la localisation de l'utilisateur.
+    :param proximity_threshold_km: Distance maximale en kilomètres pour considérer le tour comme proche.
+    :return: True si le tour est à proximité de l'utilisateur, False sinon.
+    """
+    min_distance = calculate_min_distance_to_tour(user_location, tour_coordinates)
+    return min_distance is not None and min_distance <= proximity_threshold_km
+
+def is_opportunity_for_user(tour,  user_interests):
+    """
+    Détermine si un tour représente une opportunité pour l'utilisateur.
+    
+    :param tour: Dictionnaire représentant un tour, avec une clé 'body' contenant les coordonnées du parcours.
+    :param user_id: ID de l'utilisateur.
+    :return: True si le tour représente une opportunité pour l'utilisateur, False sinon.
+    """
+    try:        
+        body = json.loads(tour['body'])
+    except json.JSONDecodeError as e:
+        print(f"Erreur lors de la conversion du corps du tour en liste de coordonnées: {e}")
+        return False
+
+    tour_coordinates = [(body[i+1], body[i]) for i in range(0, len(body), 2)]
+    
+
+    for interest in user_interests:
+        if is_within_proximity(tour_coordinates, interest['location']):
+            return True
+
+    return False
+
+def get_user_interests(user_id):
+    """
+    Récupère les centres d'intérêt d'un utilisateur basés sur les annotations, les posts ouverts et les recherches d'adresse.
+
+    :param user_id: ID de l'utilisateur
+    :return: Liste des centres d'intérêt sous forme de dictionnaires avec les coordonnées
+    """
+    interests = []
+
+    annotations_df = fetch_annotations_by_user(user_id)
+    posts_df = fetch_opened_posts(user_id)
+    searches_df = fetch_address_searches(user_id)
+
+    for index, row in annotations_df.iterrows():
+        coords = json.loads(row['coords'])
+        if coords:
+            interests.append({"location": {"lat": coords['lat'], "lon": coords['lon']}})
+
+    for index, row in posts_df.iterrows():
+        coords = json.loads(row['coords'])
+        if len(coords) % 2 == 0:
+            for i in range(0, len(coords), 2):
+                interests.append({"location": {"lat": coords[i+1], "lon": coords[i]}})
+
+    for index, row in searches_df.iterrows():
+        coords = json.loads(row['coords'])
+        if len(coords) % 2 == 0:
+            for i in range(0, len(coords), 2):
+                interests.append({"location": {"lat": coords[i+1], "lon": coords[i]}})
+
+    return interests
+
+def calculate_ocp(tours, user_id):
+    """
+    Calcul de l'Opportunité de Création de Parcours (OCP) pour un utilisateur donné.
+    """
+    opportunities = 0
+    user_interests = get_user_interests(user_id)
+    
+    # Vérifie si le DataFrame tours n'est pas vide
+    if not tours.empty:
+        # Parcourir les lignes du DataFrame tours avec iterrows()
+        for _, tour in tours.iterrows():
+            if is_opportunity_for_user(tour, user_interests):
+                opportunities += 1
+
+    # Insérer l'indicateur calculé dans la base de données
+    insert_indicator(user_id, "CAT2-Engagement", "Création Contextuelle de Parcours", "OCP", opportunities)
+
+
+def calculate_tcp(user_id):
+    """
+    Calcul du Taux de Création de Parcours (TCP) pour un utilisateur donné.
+    """
+    print('Computing TCP for user: ', user_id)
+    recommendations = fetch_recommendations()
+    N_recommendations = len(recommendations)
+
+    created_tours = fetch_tours()
+    N_parcours = len(created_tours)
+
+    TCP = N_parcours / N_recommendations if N_recommendations > 0 else 0
+
+    insert_indicator(user_id, "CAT2-Engagement", "Création Contextuelle de Parcours", "TCP", TCP)
+
+
+
+
+# --------------------------------------------
+# Stratégie 5 : Réactivation de l'Utilisateur Désengagé
+# --------------------------------------------
+
+
+def fetch_user_recommendations(user_id):
+    """
+    Récupère les recommandations envoyées à un utilisateur.
+    
+    :param user_id: ID de l'utilisateur
+    :return: DataFrame contenant les recommandations
+    """
+    # Simulation de récupération des recommandations
+    # Remplacer par une vraie récupération de données
+    data = {
+        'date_sent': [datetime.now() - timedelta(days=i) for i in range(10)],
+        'recommendation_id': range(1, 11)
+    }
+    df = pd.DataFrame(data)
+    return df
+
+def fetch_user_reactivations(user_id):
+    """
+    Récupère les données sur les réactivations d'un utilisateur après recommandation.
+    
+    :param user_id: ID de l'utilisateur
+    :return: DataFrame contenant les réactivations
+    """
+    # Simulation de récupération des réactivations
+    # Remplacer par une vraie récupération de données
+    data = {
+        'date_reactivated': [datetime.now() - timedelta(days=i) for i in range(5)],
+        'recommendation_id': [1, 2, 3, 5, 7]
+    }
+    df = pd.DataFrame(data)
+    return df
+
+def calculate_tru(user_id):
+    """
+    Calcul du Taux de Réactivation des Utilisateurs (TRU) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur
+    :return: Taux de Réactivation des Utilisateurs (TRU)
+    """
+    print('Computing TRU for user: ', user_id)
+    # Récupérer les recommandations envoyées
+    recommendations_df = fetch_user_recommendations(user_id)
+    N_recommendations = len(recommendations_df)
+    
+    # Récupérer les réactivations suite aux recommandations
+    reactivations_df = fetch_user_reactivations(user_id)
+    N_reactivés = len(reactivations_df)
+    
+    # Calculer le TRU
+    TRU = N_reactivés / N_recommendations if N_recommendations > 0 else 0
+    
+    # Insertion de l'indicateur TRU dans la base de données
+    insert_indicator(user_id, "CAT2-Engagement", "Réactivation de l’Utilisateur Désengagé", "TRU", TRU)
+    
+    return TRU
+
+
+# --------------------------------------------
+# Stratégie 6 : Recommandations basées sur les Intérêts Récurrents
+# --------------------------------------------
+def calculate_air(user_id, since_date=None):
+    """
+    Calcule l'Analyse des Intérêts Récurrents (AIR) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date depuis laquelle les annotations et parcours sont considérés.
+    :return: Valeur AIR
+    """
+    print('Computing AIR for user: ', user_id)
+    # Récupérer toutes les annotations et tours de l'utilisateur
+    annotations = fetch_annotations_by_user(user_id, since_date)
+    tours = fetch_user_tours(user_id, since_date)
+
+    # Initialiser les compteurs
+    annotation_counts = defaultdict(lambda: {'frequency': 0, 'place_type': defaultdict(int)})
+    tour_counts = defaultdict(lambda: {'count': 0, 'length': defaultdict(int)})
+
+    # Compter les occurrences de chaque type d'annotation
+    for _, row in annotations.iterrows():
+        timing = row.get('timing', 'unknown')
+        place_type = row.get('placeType', 'unknown')
+        annotation_counts[timing]['frequency'] += 1
+        annotation_counts[timing]['place_type'][place_type] += 1
+
+    # Compter les occurrences de chaque type de parcours
+    for _, row in tours.iterrows():
+        tour_type = row.get('type', 'unknown')
+        tour_length = len(json.loads(row.get('body', '[]')))  # Nombre de points dans le parcours
+        tour_counts[tour_type]['count'] += 1
+        tour_counts[tour_type]['length'][tour_length] += 1
+
+    # Calculer la fréquence totale et la moyenne
+    total_annotations = sum(d['frequency'] for d in annotation_counts.values())
+    total_tours = sum(d['count'] for d in tour_counts.values())
+    total_categories = len(annotation_counts) + len(tour_counts)
+    
+    if total_categories == 0:
+        AIR = 0
+    else:
+        AIR = (total_annotations + total_tours) / total_categories
+    
+    # Enregistrer l'indicateur AIR
+    insert_indicator(user_id, "CAT2-Engagement", "Intérêts Récurrents", "AIR", AIR)
+    
+    return AIR
+
+
+# --------------------------------------------
+# Stratégie 7 : Temps Passé sur les Annotations - TODO
+# --------------------------------------------
+
+def calculate_tpa(user_id):
+    """
+    Calcul du Temps Passé sur les Annotations (TPA) pour un utilisateur donné.
+    """
+    print('Computing TPA for user: ', user_id)
+    # Période d'évaluation (ici 30 jours avant aujourd'hui)
+    start_date = (datetime.utcnow() - timedelta(days=30)).isoformat()
+    
+    # Récupère les logs d'activité de l'utilisateur pour la période donnée
+    logs = fetch_user_logs_with_time(user_id, start_date)
+    
+    # Calculer le temps passé sur les annotations
+    total_time = 0
+    count = 0
+    
+    for log in logs:
+        begin_date = log['_source'].get('begin')
+        if begin_date:
+            begin_datetime = datetime.fromisoformat(begin_date)
+            time_spent = (datetime.utcnow() - begin_datetime).total_seconds() / 3600  # Convertir en heures
+            
+            if time_spent > 0:
+                total_time += time_spent
+                count += 1
+    
+    # Calculer le TPA
+    if count > 0:
+        TPA = total_time / count
+    else:
+        TPA = 0
+
+    # Insérer l'indicateur TPA dans la base de données
+    insert_indicator(user_id, "CAT2-Engagement", "Temps Passé sur les Annotations", "TPA", TPA)
+
+# --------------------------------------------
+# Stratégie 8 : Recommandations par Filtres Préférés
+# --------------------------------------------
+def calculate_pf(user_id, since_date=None):
+    """
+    Calcule les Préférences de Filtres (PF) pour un utilisateur donné en identifiant les motifs spécifiques récurrents.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date depuis laquelle les filtres sont considérés.
+    :return: Tuple (PF, motifs), où PF est la valeur calculée et motifs est un dictionnaire des motifs récurrents.
+    """
+    print('Computing PF for user: ', user_id)
+    # Récupérer les filtres appliqués par l'utilisateur
+    filters = fetch_user_filters(user_id, since_date)
+
+    # Initialiser les compteurs pour chaque motif de filtre
+    institution_counts = defaultdict(int)
+    nationality_counts = defaultdict(int)
+    icon_counts = defaultdict(int)
+    emoticon_counts = defaultdict(int)
+    tag_counts = defaultdict(int)
+    date_range_counts = defaultdict(int)
+
+    # Parcourir chaque filtre appliqué
+    for _, row in filters.iterrows():
+        # Compter les institutions
+        institution = row.get('institution')
+        if isinstance(institution, list):
+            for inst in institution:
+                institution_counts[inst] += 1
+
+        # Compter les nationalités
+        nationality = row.get('nationality')
+        if isinstance(nationality, list):
+            for inst in nationality:
+                nationality_counts[inst] += 1
+
+        # Compter les icônes
+        icons = row.get('icons', [])
+        if isinstance(icons, list):
+            for icon in icons:
+                icon_counts[icon] += 1
+
+        # Compter les émoticônes
+        emoticons = row.get('emoticon', [])
+        if isinstance(emoticons, list):      
+            for emoticon in emoticons:
+                emoticon_counts[emoticon] += 1
+
+        # Compter les tags
+        tags = row.get('tags', [])
+        if isinstance(tags, list):           
+            for tag in tags:
+                tag_counts[tag] += 1
+
+        # Compter les plages de dates (format standardisé de date)
+        begin_date = row.get('beginDate')
+        end_date = row.get('endDate')
+        if begin_date and end_date:
+            date_range = f"{begin_date} - {end_date}"
+            date_range_counts[date_range] += 1
+
+    # Identifier les motifs les plus fréquents
+    top_institutions = [k for k, v in institution_counts.items() if v > 1]
+    top_nationalities = [k for k, v in nationality_counts.items() if v > 1]
+    top_icons = [k for k, v in icon_counts.items() if v > 1]
+    top_emoticons = [k for k, v in emoticon_counts.items() if v > 1]
+    top_tags = [k for k, v in tag_counts.items() if v > 1]
+    top_date_ranges = [k for k, v in date_range_counts.items() if v > 1]
+
+    # Consolider les motifs
+    motifs = {
+        'institutions': top_institutions,
+        'nationalities': top_nationalities,
+        'icons': top_icons,
+        'emoticons': top_emoticons,
+        'tags': top_tags,
+        'date_ranges': top_date_ranges
+    }
+
+    # Calculer la fréquence totale et la moyenne
+    total_filters = (
+        sum(institution_counts.values()) +
+        sum(nationality_counts.values()) +
+        sum(icon_counts.values()) +
+        sum(emoticon_counts.values()) +
+        sum(tag_counts.values()) +
+        sum(date_range_counts.values())
+    )
+
+    total_motifs = len(top_institutions) + len(top_nationalities) + len(top_icons) + len(top_emoticons) + len(top_tags) + len(top_date_ranges)
+
+    if total_motifs == 0:
+        PF = 0
+    else:
+        PF = total_filters / total_motifs
+
+    # Convertir les motifs en chaîne JSON
+    motifs_json = json.dumps(motifs)
+    # Enregistrer l'indicateur PF
+    insert_indicator(user_id, "CAT2-Engagement", "Recommandations Basées sur les Filtres Préférés", "PF", PF)
+    insert_indicator(user_id, "CAT2-Engagement", "Recommandations Basées sur les Filtres Préférés", "PF_motifs", motifs_json)
+    
+    return PF, motifs
+
+# --------------------------------------------
+# Stratégie 9 : Optimisation des Sessions Courtes
+# --------------------------------------------
+
+def calculate_os(user_id):
+    """
+    Calcul de l'Optimisation des Sessions (OS) pour un utilisateur donné.
+    """
+    print('Computing OS for user: ', user_id)
+    # Période d'évaluation (ici 30 jours avant aujourd'hui)
+    start_date = (datetime.utcnow() - timedelta(days=30)).isoformat()
+    
+    # Reconstruire les sessions pour l'utilisateur
+    sessions = reconstruct_sessions(user_id, start_date)
+    
+    # Calculer la durée des sessions
+    session_durations = []
+    for session in sessions:
+        if len(session) > 1:
+            start_time = datetime.fromisoformat(session[0]['_source']['begin'].replace("Z", "+00:00"))
+            end_time = datetime.fromisoformat(session[-1]['_source']['begin'].replace("Z", "+00:00"))
+            duration = (end_time - start_time).total_seconds() / 60  # durée en minutes
+            session_durations.append(duration)
+    
+    # Calculer OS pour les sessions courtes
+    short_sessions = [duration for duration in session_durations if duration <= 10]  # Sessions courtes
+    total_sessions = len(short_sessions)
+    
+    if total_sessions > 0:
+        os = sum(short_sessions) / total_sessions
+    else:
+        os = 0
+
+    # Insérer les indicateurs OS dans la base de données
+    insert_indicator(user_id, "CAT2-Engagement", "Durée des sessions courtes", "SD", os)
+
+# --------------------------------------------
+# Stratégie 10 : Contenu Populaire
+# --------------------------------------------
+from collections import defaultdict
+import pandas as pd
+
+def build_user_profile(user_id, since_date=None):
+    """
+    Construit le profil d'intérêt de l'utilisateur à partir de ses annotations.
+
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date depuis laquelle les annotations sont considérées.
+    :return: Dictionnaire représentant le profil d'intérêt de l'utilisateur.
+    """
+    annotations = fetch_user_annotations(user_id, since_date)
+
+    if annotations.empty:
+        return None
+    
+    profile = {
+        'icons': defaultdict(int),
+        'tags': defaultdict(int),
+        'timing': defaultdict(int),
+        'placeType': defaultdict(int),
+        'text_length': defaultdict(int),
+        'has_emoticon': 0,
+        'has_image': 0
+    }
+    
+    for _, row in annotations.iterrows():
+        # Traiter les icônes
+        icons = row.get('icons', '')
+        if isinstance(icons, str):
+            icons = icons.split(',')
+        for icon in icons:
+            profile['icons'][icon.strip()] += 1
+        
+        # Traiter les tags
+        tags = row.get('tags', '')
+        if isinstance(tags, str):
+            tags = tags.split()
+        for tag in tags:
+            profile['tags'][tag.strip()] += 1
+        
+        # Traiter le timing
+        timing = row.get('timing', 'unknown')
+        profile['timing'][timing] += 1
+        
+        # Traiter le type de lieu
+        place_type = row.get('placeType', 'unknown')
+        profile['placeType'][place_type] += 1
+        
+        # Calculer la longueur du texte
+        comment = row.get('comment', '')
+        text_length = len(comment)
+        profile['text_length'][text_length] += 1
+        
+        # Vérifier la présence d'une emoticone
+        has_emoticon = 1 if row.get('emoticon') else 0
+        profile['has_emoticon'] += has_emoticon
+        
+        # Vérifier la présence d'une image
+        has_image = 1 if row.get('figure') else 0
+        profile['has_image'] += has_image
+    
+    return profile
+
+import pandas as pd
+
+def calculate_annotation_affinity(profile, annotation_details):
+    """
+    Calcule l'affinité entre une annotation et le profil d'intérêt de l'utilisateur.
+    
+    :param profile: Profil d'intérêt de l'utilisateur.
+    :param annotation_details: Détails de l'annotation, sous forme de DataFrame.
+    :return: Score d'affinité.
+    """
+    # Assurer que annotation_details est un DataFrame et obtenir la première ligne
+    if isinstance(annotation_details, pd.DataFrame):
+        if annotation_details.empty:
+            return 0  # Retourner 0 si le DataFrame est vide
+        annotation_details = annotation_details.iloc[0].to_dict()
+
+    # Poids de chaque critère
+    weight_tags = 0.3
+    weight_icons = 0.2
+    weight_timing = 0.2
+    weight_placeType = 0.15
+    weight_text_length = 0.1
+    weight_emoticon_image = 0.05
+
+    # Calcul des affinités pour chaque critère
+
+    # Tags
+    annotation_tags = annotation_details.get('tags', '')
+    if isinstance(annotation_tags, str):
+        annotation_tags = annotation_tags.split()
+    affinity_tags = sum(profile['tags'].get(tag, 0) for tag in annotation_tags) * weight_tags
+
+    # Icons
+    annotation_icons = annotation_details.get('icons', '')
+    if isinstance(annotation_icons, str):
+        annotation_icons = annotation_icons.split(',')
+    affinity_icons = sum(profile['icons'].get(icon.strip(), 0) for icon in annotation_icons) * weight_icons
+
+    # Timing
+    timing = annotation_details.get('timing', 'unknown')
+    affinity_timing = profile['timing'].get(timing, 0) * weight_timing
+    
+    # Type de lieu
+    place_type = annotation_details.get('placeType', 'unknown')
+    affinity_placeType = profile['placeType'].get(place_type, 0) * weight_placeType
+    
+    # Longueur du texte
+    text_length = len(annotation_details.get('comment', ''))
+    affinity_text_length = profile['text_length'].get(text_length, 0) * weight_text_length
+    
+    # Présence d'emoticônes et d'images
+    has_emoticon = 1 if annotation_details.get('emoticon', '') else 0
+    has_image = 1 if annotation_details.get('figure', '') else 0
+    affinity_emoticon_image = (profile['has_emoticon'] * has_emoticon +
+                               profile['has_image'] * has_image) * weight_emoticon_image
+
+    # Somme des affinités pour l'annotation
+    total_affinity = (affinity_tags + affinity_icons + affinity_timing +
+                      affinity_placeType + affinity_text_length + affinity_emoticon_image)
+
+    return total_affinity
+
+
+
+
+def calculate_aua(user_id, popularity_data, since_date=None):
+    """
+    Calcule l'Affinité Utilisateur avec les Annotations Populaires (AUA) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur.
+    :param popularity_data: Liste des annotations populaires avec leurs pourcentages de vues.
+    :param since_date: Date depuis laquelle les annotations sont considérées (optionnel).
+    :return: Score AUA et liste d'IDs des annotations avec affinité.
+    """
+    print('Computing AUA for user: ', user_id)
+    # Construire le profil d'intérêt de l'utilisateur
+    profile = build_user_profile(user_id, since_date)
+    if not profile:
+        return None
+
+    aua_score = 0
+    total_weight = 0
+    affinity_scores = []
+
+    for annotation in popularity_data:
+        annotation_id = annotation['annotation_id']
+        view_percentage = annotation['view_percentage']
+        
+        # Récupérer les détails de l'annotation
+        annotation_details = fetch_annotations_by_id(annotation_id)
+        if annotation_details.empty:
+            continue
+        
+        # Convertir les détails en dictionnaire pour la fonction d'affinité
+        annotation_dict = annotation_details.iloc[0].to_dict()
+        
+        # Calcul des affinités avec la fonction généralisée
+        affinity = calculate_annotation_affinity(profile, annotation_dict)
+        
+        # Conserver les scores d'affinité pour cette annotation
+        affinity_scores.append((annotation_id, affinity))
+        
+        aua_score += affinity * view_percentage
+        total_weight += view_percentage
+
+    # Calculer le score AUA final
+    AUA = aua_score / total_weight if total_weight > 0 else 0
+    
+    # Créer la liste des annotations avec affinité positive
+    affinity_annotations = [annotation_id for annotation_id, score in affinity_scores if score > 0]
+    
+    # Convertir la liste des IDs en chaîne JSON pour le stockage
+    affinity_annotations_json = json.dumps(affinity_annotations)
+    
+    try:
+        # Enregistrer l'indicateur AUA et les affinités dans la base de données
+        insert_indicator(user_id, "CAT2-Engagement", "Contenu Populaire", "AUA", AUA)
+        insert_indicator(user_id, "CAT2-Engagement", "Contenu Populaire", "AUA_affinities", affinity_annotations_json)
+    except SQLAlchemyError as e:
+        print(f"Error inserting indicators: {e}")
+
+    return AUA
+
+# --------------------------------------------
+# Stratégie 11 : Nouveautés à Découvrir
+# --------------------------------------------
+
+def calculate_aun(user_id, new_annotations, since_date=None):
+    """
+    Calcule l'Affinité Utilisateur avec les Nouveautés (AUN) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur.
+    :param new_annotations: DataFrame des nouvelles annotations depuis `since_date`.
+    :param since_date: Date depuis laquelle les annotations sont considérées comme nouvelles.
+    :return: Valeur AUN et liste d'IDs des nouvelles annotations avec affinité.
+    """
+    print('Computing AUN for user: ', user_id)
+    # Construire le profil d'intérêt de l'utilisateur
+    profile = build_user_profile(user_id, since_date)
+    if not profile:
+        return None
+    
+    total_affinity_score = 0
+    affinity_annotations = []
+
+    for _, annotation_row in new_annotations.iterrows():
+        # Convertir la ligne de DataFrame en dictionnaire
+        annotation = annotation_row.to_dict()
+        
+        # Calcul des affinités avec la fonction généralisée
+        affinity_score = calculate_annotation_affinity(profile, annotation)
+        total_affinity_score += affinity_score
+        
+        if affinity_score > 0:
+            affinity_annotations.append(annotation['post_id'])
+
+    # Calculer l'affinité moyenne
+    AUN = total_affinity_score / len(new_annotations) if new_annotations.shape[0] > 0 else 0
+    
+    # Convertir la liste des IDs en chaîne JSON pour le stockage
+    affinity_annotations_json = json.dumps(affinity_annotations)
+    
+    try:
+        # Enregistrer l'indicateur AUN et les affinités dans la base de données
+        insert_indicator(user_id, "CAT2-Engagement", "Nouveautés", "AUN", AUN)
+        insert_indicator(user_id, "CAT2-Engagement", "Nouveautés", "AUN_affinities", affinity_annotations_json)
+    except SQLAlchemyError as e:
+        print(f"Error inserting indicators: {e}")
+
+    return AUN
+
+
diff --git a/rs/modules/metrics/interaction_reflection_m.py b/rs/modules/metrics/interaction_reflection_m.py
new file mode 100644
index 0000000000000000000000000000000000000000..560108b3ef43248338ae42bfbc18428c8edbb9c2
--- /dev/null
+++ b/rs/modules/metrics/interaction_reflection_m.py
@@ -0,0 +1,140 @@
+from modules.db_operations import fetch_user_annotations, fetch_user_comments, insert_indicator
+from modules.es_operations import fetch_actions_count, fetch_user_actions
+
+# Valeur par défaut pour "depuis toujours"
+DEFAULT_SINCE_DATE = "0000-01-01"
+
+# --------------------------------------------
+# Catégorie 5 : Interaction et réflexion
+# --------------------------------------------
+def calculate_tac(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule le Taux d'Amélioration de la Contribution (TAC) pour un utilisateur.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les actions sont considérées.
+    :return: TAC.
+    """
+    # Récupérer les annotations et les actions de l'utilisateur depuis la date spécifiée
+    annotations = fetch_user_annotations(user_id, since_date)
+    actions = fetch_user_actions(user_id, since_date)
+    
+    # Compter les modifications et suppressions
+    M = fetch_actions_count(user_id, "m:editPost")
+    S = fetch_actions_count(user_id, "m:deletePost")
+    
+    # Définir les types d'interactions à considérer
+    interaction_types = {'m:openPost', 'm:seePostsMode', 'm:showNotifications', 'm:readNotification', 
+                          'm:selectNotification', 'm:addPost', 'm:editPost', 'm:startTour', 
+                          'm:addMessage', 'm:addEmoji', 'm:movePost', 'm:editTour', 'm:addFavorite', 
+                          'm:moveTour', 'm:addressSearch', 'm:Filter'}
+    
+    # Compter les interactions totales
+    # L = sum(1 for act in actions if act.get('action_type') in interaction_types)
+    total_actions = {action: fetch_actions_count(user_id, action) for action in interaction_types}
+
+    # Calcul du total de toutes les actions
+     
+    L = sum(total_actions.values())
+    
+    # Calculer le TAC
+    tac = (M + S) / L if L else 0
+    
+    # Insérer l'indicateur dans la base de données
+    insert_indicator(
+        user_id,
+        "Interaction et réflexion",
+        "Auto-Évaluation et Amélioration des Contributions",
+        "TAC",
+        tac
+    )
+    
+    return tac
+
+def calculate_tec(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule le Taux d'Engagement Communautaire (TEC) pour un utilisateur.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les actions sont considérées.
+    :return: TEC.
+    """
+    # Récupérer les commentaires de l'utilisateur depuis la date spécifiée
+    comments = fetch_user_comments(user_id, since_date)
+    
+    # Compter les commentaires et les réponses
+    C = fetch_actions_count(user_id, 'm:addMessage')  # Nombre de commentaires
+    R = fetch_actions_count(user_id, 'm:addEmoji')  # Nombre de réponses aux commentaires
+    
+    # Nombre total de contributions reçues
+    L = len(comments)
+    
+    # Calculer le TEC
+    tec = (C + R) / L if L else 0
+    
+    # Insérer l'indicateur dans la base de données
+    insert_indicator(
+        user_id,
+        "Interaction et réflexion",
+        "Interaction avec les Contributions Communautaires",
+        "TEC",
+        value=tec
+    )
+    
+
+
+def calculate_trc(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule le Taux de Réponse aux Commentaires (TRC) pour un utilisateur.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les actions sont considérées.
+    :return: TRC.
+    """
+    # Récupérer les commentaires reçus de l'utilisateur depuis la date spécifiée
+    comments = fetch_user_comments(user_id, since_date)
+    
+    # Compter les réponses aux commentaires
+    R = fetch_actions_count(user_id, 'm:addEmoji')  # Réponses aux commentaires
+    
+    # Nombre total de commentaires reçus
+    C = len(comments)
+    
+    # Calculer le TRC
+    trc = R / C if C else 0
+    
+    # Insérer l'indicateur dans la base de données
+    insert_indicator(
+        user_id,
+        "Interaction et réflexion",
+        "Réactivité aux Commentaires sur les Contributions",
+        "TRC",
+        trc
+    )
+    
+    return trc
+
+def calculate_tcc(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule le Taux de Contribution aux Commentaires (TCC) pour un utilisateur.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les actions sont considérées.
+    :return: TCC.
+    """
+    annotations = fetch_user_annotations(user_id, since_date)
+    comments = fetch_user_comments(user_id, since_date)
+    
+    # Compter les commentaires faits
+    C = fetch_actions_count(user_id, 'm:addMessage')  # Commentaires faits
+    
+    # Contributions commentables
+    T = len(annotations) + len(comments)
+    
+    # Calculer le TCC
+    tcc = C / T if T else 0
+    
+    # Insérer l'indicateur dans la base de données
+    insert_indicator(user_id, "Interaction et réflexion", "Taux de Contribution aux Commentaires", "TCC", tcc)
+    
+    return tcc
\ No newline at end of file
diff --git a/rs/modules/metrics/urban_discovery_m.py b/rs/modules/metrics/urban_discovery_m.py
new file mode 100644
index 0000000000000000000000000000000000000000..361c2d8514cfd5d3b9a2314c5db9bdb8dbd5aab6
--- /dev/null
+++ b/rs/modules/metrics/urban_discovery_m.py
@@ -0,0 +1,610 @@
+import numpy as np
+from sklearn.cluster import DBSCAN
+from geopy.distance import geodesic
+from shapely.geometry import Polygon, MultiPoint
+from math import radians, sin, cos, sqrt, atan2
+import json
+from modules.db_operations import (
+    fetch_annotations_around_location, fetch_annotations_by_user, fetch_annotations_with_coordinates, 
+    insert_indicator
+)
+from modules.es_operations import fetch_user_traces, fetch_visited_locations
+
+DEFAULT_SINCE_DATE = "2000-01-01"
+
+# Utilitaires géographiques
+def calculate_area_surface(coordinates):
+    """
+    Calcule la surface d'une zone urbaine définie par une série de coordonnées (latitude, longitude).
+    
+    :param coordinates: Liste des coordonnées (latitude, longitude) définissant le polygone.
+    :return: Surface de la zone urbaine en mètres carrés.
+    """
+    if len(coordinates) < 3:
+        # Un polygone doit avoir au moins 3 points
+        print("Pas assez de points pour définir un polygone.")
+        return 0
+    
+    # Création d'un polygone à partir des coordonnées
+    polygon = Polygon(coordinates)
+    
+    # La surface retournée par shapely est en unités de degré carré
+    # Convertir en mètres carrés en utilisant une approximation
+    # Note : La conversion exacte dépend de la localisation et de la projection
+    # Ici, on utilise la conversion simplifiée en degrés pour un calcul approximatif.
+    area_m2 = polygon.area * (40008000 / 360) ** 2  # Approximation en utilisant la largeur de la terre
+    
+    return area_m2
+
+def calculate_distance(coord1, coord2):
+    """
+    Calcule la distance entre deux coordonnées géographiques en kilomètres.
+    
+    :param coord1: Premier point (latitude, longitude).
+    :param coord2: Deuxième point (latitude, longitude).
+    :return: Distance en kilomètres.
+    """
+    return geodesic((coord1['lat'], coord1['lon']), (coord2['lat'], coord2['lon'])).kilometers
+
+
+def calculate_harv_distance(coord1, coord2):
+    """
+    Calcule la distance de Haversine entre deux points (latitude, longitude) en kilomètres.
+    
+    :param coord1: Tuple (lat, lon) du premier point.
+    :param coord2: Tuple (lat, lon) du second point.
+    :return: Distance en kilomètres.
+    """
+    lat1, lon1 = radians(coord1[0]), radians(coord1[1])
+    lat2, lon2 = radians(coord2[0]), radians(coord2[1])
+    
+    dlat = lat2 - lat1
+    dlon = lon2 - lon1
+    
+    a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
+    c = 2 * atan2(sqrt(a), sqrt(1 - a))
+    
+    R = 6371  # Rayon de la Terre en kilomètres
+    return R * c
+
+
+# Fonction utilitaire pour obtenir le nombre d'annotations autour de chaque lieu
+def get_annotations_count_around_locations(locations):
+    """
+    Compte le nombre total d'annotations autour des lieux donnés.
+    
+    :param locations: Liste des coordonnées des lieux.
+    :return: Nombre total d'annotations.
+    """
+    total_annotations = 0
+    for location in locations:
+        annotations = fetch_annotations_around_location(location)
+        total_annotations += len(annotations)
+    return total_annotations
+
+
+# Catégorie 4 : Découverte Urbaine
+# Stratégie 1 : Découverte de POI Basée sur les Tendances de Visite
+def calculate_dap(user_id):
+    """
+    Calcule la densité d'annotations autour des POI liés aux lieux visités par un utilisateur.
+    
+    :param user_id: ID de l'utilisateur.
+    """
+    # Récupérer les lieux visités par l'utilisateur
+    visited_locations = fetch_visited_locations(user_id)
+    
+    if not visited_locations:
+        print(f"Aucun lieu visité trouvé pour l'utilisateur {user_id}. DAP sera défini à 0.")
+        dap = 0
+        insert_indicator(
+            user_id,
+            "Découverte Urbaine",
+            "Recommandation à partir des tendances de visite",
+            "DAP",
+            dap
+        )
+        return
+
+    print(f"Lieux visités par l'utilisateur {user_id} : {visited_locations}")
+    
+    # Convertir les lieux visités en POI (Points of Interest)
+    visited_pois = get_pois_nearby(visited_locations)
+    
+    if not visited_pois:
+        print(f"Aucun POI trouvé autour des lieux visités par l'utilisateur {user_id}. DAP sera défini à 0.")
+        dap = 0
+        insert_indicator(
+            user_id,
+            "Découverte Urbaine",
+            "Recommandation à partir des tendances de visite",
+            "DAP",
+            dap
+        )
+        return
+    
+    print(f"POIs proches des lieux visités par l'utilisateur {user_id} : {visited_pois}")
+    
+    # Calculer la densité d'annotations
+    total_annotations = sum(poi['annotations_count'] for poi in visited_pois)
+    total_pois = len(visited_pois)
+    
+    # Calcul de la DAP : si aucun POI, on retourne une densité de 0
+    dap = total_annotations / total_pois if total_pois > 0 else 0
+
+    print(f"DAP pour l'utilisateur {user_id} : {dap}")
+    
+    # Insérer l'indicateur DAP dans la base de données
+    insert_indicator(
+        user_id,
+        "Découverte Urbaine",
+        "Recommandation à partir des tendances de visite",
+        "DAP",
+        dap
+    )
+
+def get_average_dap_for_similar_zones(visited_locations, zone_radius=1.0):
+    """
+    Calcule la densité moyenne d'annotations dans des zones similaires aux lieux visités.
+    
+    :param visited_locations: Liste des coordonnées des lieux visités.
+    :param zone_radius: Rayon de recherche autour de chaque lieu pour définir des zones similaires (en km).
+    :return: Densité moyenne d'annotations dans ces zones similaires.
+    """
+    all_annotations = fetch_annotations_with_coordinates()
+    
+    if not all_annotations:
+        return 0
+    
+    total_dap = 0
+    count_zones = 0
+    
+    for location in visited_locations:
+        lat, lon = location['lat'], location['lon']
+        
+        # Filtrer les annotations proches de chaque lieu visité
+        nearby_annotations = fetch_annotations_around_location({'lat': lat, 'lon': lon}, zone_radius)
+        
+        if nearby_annotations:
+            # Calcul de la DAP pour cette zone
+            surface_area = np.pi * (zone_radius ** 2)  # Surface d'un cercle avec le rayon donné
+            dap = len(nearby_annotations) / surface_area
+            total_dap += dap
+            count_zones += 1
+    
+    # Retourner la DAP moyenne pour toutes les zones similaires
+    average_dap = total_dap / count_zones if count_zones > 0 else 0
+    return average_dap
+
+
+def calculate_prl(user_id):
+    """
+    Calcule la popularité relative des POI liés aux lieux visités par un utilisateur.
+    """
+    visited_locations = fetch_visited_locations(user_id)
+    if not visited_locations:
+        print(f"Aucun lieu visité trouvé pour l'utilisateur {user_id}.")
+        return
+    
+    # Convertir les lieux visités en POI (Points of Interest)
+    visited_pois = get_pois_nearby(visited_locations)
+    if not visited_pois:
+        print("Aucun POI trouvé autour des lieux visités.")
+        return
+    
+    # Calcul de la DAP pour les lieux visités
+    total_annotations = sum(poi['annotations_count'] for poi in visited_pois)
+    total_pois = len(visited_pois)
+    dap = total_annotations / total_pois if total_pois > 0 else 0
+
+    # Calcul du DAP moyen pour les zones similaires
+    dap_mean = get_average_dap_for_similar_zones(visited_locations)
+
+    # Calcul de la PRL
+    prl = dap / dap_mean if dap_mean > 0 else 0
+
+    # Insérer l'indicateur PRL dans la base de données
+    insert_indicator(
+        user_id,
+        "Découverte Urbaine",
+        "Recommandation à partir des tendances de visite",
+        "PRL",
+        prl
+    )
+
+
+# Stratégie 2 : Exploration Équilibrée des Zones Urbaines
+def calculate_dmapp(user_id):
+    """
+    Calcule la DMAPP (Distance Moyenne des Points de Proximité) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur
+    """
+    # Récupérer les traces de l'utilisateur
+    user_df = fetch_user_traces(user_id)
+    
+    if user_df.empty:
+        print(f"DMAPP - Aucune trace trouvée pour l'utilisateur {user_id}. DMAPP sera défini à NULL.")
+        dmapp_value = None  # Utiliser None pour insérer NULL dans la base de données
+        insert_indicator(user_id, "Exploration Équilibrée des Zones Urbaines", "DMAPP", "DMAPP", dmapp_value)
+        return
+    
+    # Vérifier si le DataFrame contient les colonnes pour les coordonnées
+    if 'coordinates' not in user_df.columns:
+        print(f"DMAPP - Les coordonnées ne sont pas disponibles pour l'utilisateur {user_id}. DMAPP sera défini à NULL.")
+        dmapp_value = None  # Utiliser None pour insérer NULL dans la base de données
+        insert_indicator(user_id, "Exploration Équilibrée des Zones Urbaines", "DMAPP", "DMAPP", dmapp_value)
+        return
+    
+    print(f"Traces de l'utilisateur {user_id} : {user_df[['coordinates']].head()}")
+    
+    # Liste pour stocker les distances DMAPP
+    dmapp_distances = []
+    
+    # Itérer sur chaque ligne pour calculer les distances par rapport à tous les autres points
+    for index, row in user_df.iterrows():
+        print(f"Calcul DMAPP pour l'utilisateur {user_id}, ligne {index}: {row['coordinates']}")
+        
+        # Supposons que les coordonnées sont un dictionnaire avec des clés 'lat' et 'lon'
+        lat_radians = np.radians(row['coordinates']['lat'])
+        lon_radians = np.radians(row['coordinates']['lon'])
+        
+        # Calculer les distances entre ce point et tous les autres points
+        distances = user_df.apply(
+            lambda x: calculate_harv_distance(
+                (lat_radians, lon_radians), 
+                (np.radians(x['coordinates']['lat']), np.radians(x['coordinates']['lon']))
+            ), 
+            axis=1
+        )
+        
+        # Exclure les distances nulles (comparaison avec soi-même) et calculer la plus petite distance
+        min_distance = distances[distances > 0].min() if not distances.empty else None
+        dmapp_distances.append(min_distance)
+    
+    # Conversion des distances en un tableau NumPy
+    dmapp_distances_np = np.array(dmapp_distances)
+    
+    # Calcul de la moyenne des distances, en ignorant les valeurs NaN
+    dmapp_value = np.nanmean(dmapp_distances_np) if dmapp_distances_np.size > 0 else None
+    
+    print(f"DMAPP pour l'utilisateur {user_id} : {dmapp_value}")
+    
+    # Insertion de l'indicateur DMAPP dans la base de données
+    insert_indicator(user_id, "Exploration Équilibrée des Zones Urbaines", "DMAPP", "DMAPP", dmapp_value)
+
+
+#  surface du polygone convexe englobant
+def calculate_spc(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule la surface du polygone convexe englobant (SPC) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date depuis laquelle les traces doivent être récupérées.
+    """
+    # Récupérer les traces de l'utilisateur
+    user_df = fetch_user_traces(user_id, since_date)
+    
+    if user_df.empty:
+        print(f"SPC - Aucune trace trouvée pour l'utilisateur {user_id}. SPC sera défini à NULL.")
+        spc_value = None  # Utiliser None pour insérer NULL dans la base de données
+        insert_indicator(user_id, "Exploration Équilibrée des Zones Urbaines", "SPC", "SPC", spc_value)
+        return
+    
+    # Vérification de la présence de la colonne 'coordinates'
+    if 'coordinates' not in user_df.columns:
+        print(f"SPC - Les coordonnées ne sont pas disponibles pour l'utilisateur {user_id}. SPC sera défini à NULL.")
+        spc_value = None  # Utiliser None pour insérer NULL dans la base de données
+        insert_indicator(user_id, "Exploration Équilibrée des Zones Urbaines", "SPC", "SPC", spc_value)
+        return
+    
+    # Extraction des coordonnées (lon, lat) à partir du champ 'coordinates'
+    points = list(zip(user_df['coordinates'].apply(lambda x: x['lon']), 
+                      user_df['coordinates'].apply(lambda x: x['lat'])))
+    
+    # Vérification du nombre de points (au moins 3 points nécessaires pour un polygone convexe)
+    if len(points) < 3:
+        print(f"SPC - Pas assez de points pour calculer le polygone convexe pour l'utilisateur {user_id}. SPC sera défini à NULL.")
+        spc_value = None  # Utiliser None pour insérer NULL dans la base de données
+        insert_indicator(user_id, "Exploration Équilibrée des Zones Urbaines", "SPC", "SPC", spc_value)
+        return
+    
+    # Calcul du polygone convexe et de la surface
+    convex_hull = MultiPoint(points).convex_hull
+    spc_value = convex_hull.area if convex_hull else None  # Utiliser None si le polygone n'est pas valide
+    
+    print(f"SPC pour l'utilisateur {user_id} : {spc_value}")
+    
+    # Insertion de l'indicateur SPC dans la base de données
+    insert_indicator(user_id, "Exploration Équilibrée des Zones Urbaines", "SPC", "SPC", spc_value)
+
+
+# Stratégie 3 : Diversification des Types d’Annotations Urbaines
+def calculate_dau(user_id, since_date=DEFAULT_SINCE_DATE):
+    """
+    Calcule la diversité des annotations urbaines pour un utilisateur.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les annotations sont considérées.
+    :return: Diversité des annotations urbaines.
+    """
+    # Récupération des annotations de l'utilisateur depuis la date spécifiée
+    annotations_df = fetch_annotations_by_user(user_id, since_date)
+    
+    if annotations_df.empty:
+        print(f"DAU - Aucune annotation trouvée pour l'utilisateur {user_id}. DAU sera défini à 0.")
+        return 0
+    
+    # Extraction des coordonnées des annotations
+    coordinates = []
+    for _, row in annotations_df.iterrows():
+        coords = row['coords']  # Adaptez selon le nom de colonne réel
+        coords_dict = json.loads(coords)
+        lat = coords_dict.get('lat')
+        lon = coords_dict.get('lon')
+        if lat is not None and lon is not None:
+            coordinates.append((lat, lon))
+    
+    # Récupération des zones urbaines dynamiques
+    urban_areas = get_dynamic_urban_areas(coordinates)
+    
+    # Comptage des types d'annotations par zone
+    annotation_types_per_area = {area: set() for area in urban_areas}
+    
+    for _, row in annotations_df.iterrows():
+        coords = row['coords']
+        coords_dict = json.loads(coords)
+        lat = coords_dict.get('lat')
+        lon = coords_dict.get('lon')
+        if lat is not None and lon is not None:
+            for area_id, area_coords in urban_areas.items():
+                if any(calculate_harv_distance((lat, lon), coord) < 0.01 for coord in area_coords):
+                    annotation_types_per_area[area_id].add(row['placeType'])
+    
+    # Calcul de la diversité
+    total_types = sum(len(types) for types in annotation_types_per_area.values())
+    total_areas = len(annotation_types_per_area)
+    
+    dau = total_types / total_areas if total_areas > 0 else 0
+
+    # Insérer l'indicateur dans la base de données
+    insert_indicator(
+        user_id,
+        "Découverte Urbaine",
+        "Diversité des Annotations Urbaines",
+        "DAU",
+        dau
+    )
+
+
+
+# Stratégie 4 : Réduction de la Concentration des Annotations
+def calculate_dc(user_id):
+    annotations_df = fetch_annotations_by_user(user_id)
+    if annotations_df.empty:
+        print(f"Aucune annotation trouvée pour l'utilisateur {user_id}.")
+        return
+    coordinates = [(json.loads(row['coords'])['lat'], json.loads(row['coords'])['lon']) for _, row in annotations_df.iterrows()]
+    cluster_data = get_clusters(coordinates)
+    concentration_scores = []
+    for cluster_id, coords in cluster_data.items():
+        num_annotations = len(coords)
+        area_surface = calculate_area_surface(coords)
+        if area_surface > 0:
+            concentration_scores.append(num_annotations / area_surface)
+    dc_value = np.mean(concentration_scores) if concentration_scores else 0
+    insert_indicator(user_id, "Découverte Urbaine", "Degré de Clusterisation", "DC", dc_value)
+
+def calculate_dsa(user_id):
+    annotations_df = fetch_annotations_by_user(user_id)
+    if annotations_df.empty:
+        print(f"Aucune annotation trouvée pour l'utilisateur {user_id}.")
+        return
+    coordinates = [(json.loads(row['coords'])['lat'], json.loads(row['coords'])['lon']) for _, row in annotations_df.iterrows()]
+    clusters = get_clusters(coordinates)
+    total_annotations = len(coordinates)
+    cluster_annotations = sum(len(cluster) for cluster in clusters.values())
+    dsa_value = cluster_annotations / total_annotations if total_annotations > 0 else 0
+    insert_indicator(user_id, "Découverte Urbaine", "DSA", "DSA", dsa_value)
+
+# Fonction auxiliaire pour obtenir les POI à proximité des lieux visités
+def get_pois_nearby(visited_locations, eps=0.2, min_samples=3):
+    """
+    Génère des Points d’Intérêt (POI) potentiels à partir des annotations et sélectionne 
+    ceux qui sont à proximité des lieux visités par l'utilisateur.
+
+    :param visited_locations: Liste des coordonnées des lieux visités par l'utilisateur (en degrés).
+    :param eps: Distance maximale entre deux points pour les considérer dans le même cluster (en kilomètres).
+    :param min_samples: Nombre minimum d'annotations pour former un POI.
+    :return: Liste de POI à proximité des lieux visités par l'utilisateur.
+    """
+    # Récupérer les annotations avec coordonnées
+    annotations_with_coords = fetch_annotations_with_coordinates()
+    if not annotations_with_coords:
+        print("Aucune annotation avec coordonnées trouvée.")
+        return []
+
+    # Extraire les coordonnées et les IDs des annotations
+    coords = np.array([(lat, lon) for _, lat, lon in annotations_with_coords])
+    annotation_ids = [annotation_id for annotation_id, _, _ in annotations_with_coords]
+
+    # Convertir les coordonnées en radians
+    coords_radians = np.radians(coords)
+
+    # Appliquer DBSCAN pour regrouper les annotations en POI potentiels
+    clustering = DBSCAN(eps=eps / 6371.0, min_samples=min_samples, metric='haversine').fit(coords_radians)
+    
+    # Associer chaque annotation à son cluster
+    clusters = {}
+    for idx, label in enumerate(clustering.labels_):
+        if label == -1:
+            continue  # Skip noise points
+        if label not in clusters:
+            clusters[label] = []
+        clusters[label].append((annotation_ids[idx], annotations_with_coords[idx]))  # Inclure l'ID d'annotation
+
+    # Construire des POI à partir des clusters
+    pois = []
+    for cluster_id, points in clusters.items():
+        if len(points) >= min_samples:
+            # Calculer le centre géographique du cluster comme POI
+            lat_mean = np.mean([lat for _, (annotation_id, lat, lon) in points])
+            lon_mean = np.mean([lon for _, (annotation_id, lat, lon) in points])
+            annotations_count = len(points)
+            annotation_ids = [annotation_id for annotation_id, _ in points]  # Collecter les IDs des annotations
+            pois.append({
+                'id': f'poi_{cluster_id}',
+                'name': f'POI {cluster_id}',
+                'description': f'POI avec {annotations_count} annotations.',
+                'lat': lat_mean,
+                'lon': lon_mean,
+                'annotations_count': annotations_count,
+                'annotation_ids': annotation_ids  # Ajouter les IDs d'annotations ici
+            })
+
+    # Filtrer les POI qui sont proches des lieux visités par l'utilisateur
+    nearby_pois = []
+    for poi in pois:
+        for visited_location in visited_locations:
+            visited_lat = visited_location['lat']
+            visited_lon = visited_location['lon']
+            distance = haversine_distance((poi['lat'], poi['lon']), (visited_lat, visited_lon))
+            if distance <= eps:  # Distance en kilomètres
+                nearby_pois.append(poi)
+                break  # On ne rajoute qu'une fois par POI
+    
+    # Après récupération des annotations
+    print(f"Nombre d'annotations récupérées : {len(annotations_with_coords)}")
+
+    # Après clustering DBSCAN
+    print(f"Labels après clustering : {set(clustering.labels_)}")
+
+    # Nombre de POI potentiels créés
+    print(f"Nombre de POI créés : {len(pois)}")
+
+    # Filtrage des POI à proximité
+    print(f"POI avant filtrage par proximité : {len(pois)}")
+
+    return nearby_pois
+
+
+def haversine_distance(coord1, coord2):
+    """
+    Calcule la distance de Haversine entre deux points en kilomètres.
+
+    :param coord1: Tuple (lat, lon) du premier point.
+    :param coord2: Tuple (lat, lon) du second point.
+    :return: Distance en kilomètres.
+    """
+    lat1, lon1 = radians(coord1[0]), radians(coord1[1])
+    lat2, lon2 = radians(coord2[0]), radians(coord2[1])
+    dlat = lat2 - lat1
+    dlon = lon2 - lon1
+
+    a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2
+    c = 2 * atan2(sqrt(a), sqrt(1 - a))
+
+    # Rayon de la Terre en kilomètres
+    R = 6371.0
+    return R * c
+
+# Fonction auxiliaire pour obtenir les zones urbaines dynamiques
+def get_dynamic_urban_areas(coordinates):
+    urban_areas = {}
+    clustering = DBSCAN(eps=0.2, min_samples=5).fit(coordinates)
+    for label in set(clustering.labels_):
+        if label == -1:
+            continue
+        cluster_coords = [coord for i, coord in enumerate(coordinates) if clustering.labels_[i] == label]
+        if len(cluster_coords) >= 5:
+            urban_areas[label] = cluster_coords
+    return urban_areas
+
+# Fonction auxiliaire pour obtenir les clusters d'annotations
+def get_clusters(coordinates, eps=0.2, min_samples=5):
+    clustering = DBSCAN(eps=eps / 6371.0, min_samples=min_samples, metric='haversine').fit(np.radians(coordinates))
+    clusters = {}
+    for idx, label in enumerate(clustering.labels_):
+        if label == -1:
+            continue
+        if label not in clusters:
+            clusters[label] = []
+        clusters[label].append(coordinates[idx])
+    return clusters
+
+
+def get_unexplored_zones(user_id, annotations, min_distance=0.5):
+    """
+    Identifie les zones inexplorées autour des annotations existantes.
+    
+    :param user_id: ID de l'utilisateur.
+    :param annotations: Liste des coordonnées (lat, lon) des annotations de l'utilisateur.
+    :param min_distance: Distance minimale en kilomètres pour considérer une zone comme inexplorée.
+    :return: Liste de coordonnées représentant des zones inexplorées.
+    """
+    if not annotations:
+        print(f"Aucune annotation trouvée pour l'utilisateur {user_id}.")
+        return []
+    
+    # Extraire les coordonnées des annotations
+    print(type(annotations))
+    print(annotations)
+    # coordinates = [(json.loads(ann['coords'])['lat'], json.loads(ann['coords'])['lon']) for ann in annotations]
+    coordinates = [(lat, lon) for lat, lon in annotations]
+    
+    # Clustering des annotations pour définir les zones explorées
+    clustering = DBSCAN(eps=min_distance, min_samples=5, metric=calculate_harv_distance).fit(coordinates)
+    
+    # Obtenir les clusters de zones explorées
+    explored_zones = [coordinates[i] for i in range(len(coordinates)) if clustering.labels_[i] != -1]
+    
+    # Déterminer les zones inexplorées (en dehors des clusters)
+    unexplored_zones = []
+    
+    for coord in coordinates:
+        is_far = all(calculate_harv_distance(coord, explored) > min_distance for explored in explored_zones)
+        if is_far:
+            unexplored_zones.append(coord)
+    
+    return unexplored_zones
+
+
+
+def calculate_pze(user_id):
+    """
+    Calcule la proximité aux zones non exploitées (PZE) pour un utilisateur donné.
+    
+    :param user_id: ID de l'utilisateur.
+    :return: Proximité aux zones non exploitées (PZE).
+    """
+    # Récupérer les traces (annotations) de l'utilisateur
+    annotations_df = fetch_annotations_by_user(user_id)
+    
+    if annotations_df.empty:
+        print(f"Aucune annotation trouvée pour l'utilisateur {user_id}.")
+        return None
+    
+    # Obtenir les coordonnées des annotations de l'utilisateur
+    user_annotations = [(json.loads(row['coords'])['lat'], json.loads(row['coords'])['lon']) 
+                        for _, row in annotations_df.iterrows()]
+    
+    # Déterminer les zones non explorées
+    unexplored_zones = get_unexplored_zones(user_id, user_annotations)
+    
+    if not unexplored_zones:
+        print("Aucune zone non explorée trouvée.")
+        return None
+
+    # Calculer la distance minimale entre chaque annotation de l'utilisateur et les zones non explorées
+    distances_to_unexplored = []
+    for annotation in user_annotations:
+        min_distance = min(calculate_harv_distance(annotation, zone) for zone in unexplored_zones)
+        distances_to_unexplored.append(min_distance)
+    
+    # Calculer la distance moyenne aux zones non explorées
+    pze_value = np.mean(distances_to_unexplored) if distances_to_unexplored else np.nan
+    
+    # Insérer l'indicateur dans la base de données
+    insert_indicator(user_id, "Exploration Équilibrée des Zones Urbaines", "Proximité aux Zones Non Exploitées", "PZE", pze_value)
+    
+    return pze_value
diff --git a/rs/modules/notification_service.py b/rs/modules/notification_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..f01e5f3dd9ed3c3234f5d5d80199b24b6c33df17
--- /dev/null
+++ b/rs/modules/notification_service.py
@@ -0,0 +1,191 @@
+from datetime import datetime, timedelta
+from sqlalchemy import and_
+from modules.db_operations import fetch_recommendations, fetch_users_with_pending_recommendations, get_user_registration_date, update_recommendation_status
+from web_app.models import Recommendation
+from sqlalchemy.exc import SQLAlchemyError
+import logging
+import pandas as pd
+
+# Configuration de la journalisation
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+
+# Durée de la phase de découverte en jours
+DISCOVERY_PERIOD_DAYS = 15
+# Période minimale entre deux recommandations identiques (en jours)
+MIN_RECO_FREQUENCY_DAYS = 30
+
+def notify_user(user_id, recommendation):
+    """
+    Envoie une notification de recommandation à un utilisateur et met à jour le statut en 'Sent'.
+    """
+    try:
+        logging.info(f"Sending notification for User {user_id}: {recommendation['strategy']}")
+        
+        # Mettre à jour le statut de la recommandation en 'Sent'
+        update_recommendation_status(recommendation['id'], 'Sent')
+        logging.info("Notification sent and status updated.")
+    except Exception as e:
+        logging.error(f"Error sending notification: {e}")
+
+def is_in_discovery_phase(user_id):
+    """
+    Vérifie si l'utilisateur est encore dans sa phase de découverte.
+    """
+    user_signup_date = datetime.fromisoformat(get_user_registration_date(user_id))
+    print('User:', user_id, ' - User_signup_date: ', user_signup_date)
+    discovery_end_date = user_signup_date + timedelta(days=DISCOVERY_PERIOD_DAYS)
+    return datetime.utcnow() < discovery_end_date
+
+def get_recent_recommendations(recommendations_df, user_id):
+    """
+    Récupère les recommandations envoyées récemment à un utilisateur (dans les 30 derniers jours).
+    """
+    thirty_days_ago = datetime.utcnow() - timedelta(days=MIN_RECO_FREQUENCY_DAYS)
+    
+    # Filtrer les recommandations du DataFrame
+    recent_recommendations = recommendations_df[
+        (recommendations_df['user_id'] == user_id) &
+        (recommendations_df['status'] == 'Sent') &
+        (recommendations_df['created_at'] >= thirty_days_ago)
+    ]
+    
+    return recent_recommendations['strategy'].tolist()
+
+def get_recommendations_by_category(recommendations_df, strategy_list):
+    """
+    Récupère les recommandations par catégorie spécifiée.
+    """
+    return recommendations_df[
+        (recommendations_df['strategy'].isin(strategy_list)) &
+        (recommendations_df['status'] == 'Created')
+    ]
+
+def discard_recommendation(recommendation_id):
+    """
+    Marque une recommandation comme 'Discarded'.
+    """
+    try:
+        update_recommendation_status(recommendation_id, 'Discarded')
+        logging.info(f"Recommendation {recommendation_id} has been discarded.")
+    except Exception as e:
+        logging.error(f"Error discarding recommendation {recommendation_id}: {e}")
+
+def discard_previous_recommendations(user_id, new_strategy, recommendations_df):
+    """
+    Écarte les recommandations précédentes du même type pour un utilisateur donné.
+    """
+    existing_recommendations = recommendations_df[
+        (recommendations_df['user_id'] == user_id) & 
+        (recommendations_df['strategy'] == new_strategy) & 
+        (recommendations_df['status'].isin(['Ready', 'Created']))
+    ]
+    
+    for _, row in existing_recommendations.iterrows():
+        discard_recommendation(row['id'])
+
+def should_discard_recommendation(strategy, user_id, recommendations_df):
+    """
+    Vérifie si une recommandation doit être écartée en fonction de son type et des recommandations récentes.
+    """
+    recent_recommendations = get_recent_recommendations(recommendations_df, user_id)
+    
+    if strategy in recent_recommendations:
+        return True  # Une recommandation de même type a déjà été envoyée récemment
+
+    existing_recommendations = recommendations_df[
+        (recommendations_df['strategy'] == strategy) &
+        (recommendations_df['user_id'] == user_id) &
+        (recommendations_df['status'].isin(["Ready", "Sent"]))
+    ]
+    
+    return len(existing_recommendations) > 0
+
+
+def select_recommendations(user_id):
+    """
+    Sélectionne une seule recommandation appropriée pour un utilisateur donné en fonction des règles définies.
+    """
+    # Récupérer toutes les recommandations de l'utilisateur
+    recommendations_df = fetch_recommendations(user_id)
+
+    # Si l'utilisateur n'a pas de recommandations, retourner None
+    if recommendations_df.empty:
+        logging.info(f"Aucune recommandation trouvée pour l'utilisateur {user_id}")
+        return None
+
+    # Vérifier si l'utilisateur est encore dans sa phase de découverte
+    if is_in_discovery_phase(user_id):
+        discovery_strategies = ['InitSupport', 'FuncCreateShare', 'FuncContentExplore', 'FuncInteraction']
+        recommendations = get_recommendations_by_category(recommendations_df, discovery_strategies)
+    else:
+        # Sélectionner les stratégies après la période de découverte
+        corrective_strategies = ['QualityImprove', 'TextIncentive', 'SymbolEnrich', 'GraphicalExpress',
+                                 'POIDiscover', 'CoverageExpand', 'ConcentrationReduce']
+        recommendations = get_recommendations_by_category(recommendations_df, corrective_strategies)
+
+        if recommendations.empty:
+            engagement_strategies = ['AnnotationProfileEngage', 'AnnotationLocationEngage', 'RouteSuggest', 
+                                     'UserActivityReengage', 'UserCreationReengage', 'UserExplorationReengage',
+                                     'UserInteractionReengage', 'AnnotationHistoryReengage', 'TourHistoryReengage', 
+                                     'CommunityReengage']
+            recommendations = get_recommendations_by_category(recommendations_df, engagement_strategies)
+
+    if recommendations.empty:
+        logging.info(f"Aucune recommandation applicable pour l'utilisateur {user_id}.")
+        return None
+
+    # Écarter les recommandations précédentes du même type
+    new_strategy = recommendations.iloc[0]['strategy']  # Sélectionner la stratégie de la première recommandation
+    discard_previous_recommendations(user_id, new_strategy, recommendations_df)
+
+    # Règles de priorité pour 'QualityImprove' et 'UserActivityReengage'
+    if 'QualityImprove' in recommendations['strategy'].values:
+        recommendations = recommendations[~recommendations['strategy'].isin(['TextIncentive', 'SymbolEnrich', 'GraphicalExpress'])]
+
+    if 'UserActivityReengage' in recommendations['strategy'].values:
+        recommendations = recommendations[~recommendations['strategy'].isin(['UserCreationReengage', 'UserExplorationReengage', 'UserInteractionReengage'])]
+
+    # Filtrer les recommandations à écarter
+    filtered_recommendations = recommendations[~recommendations['strategy'].apply(
+        lambda x: should_discard_recommendation(x, user_id, recommendations_df))]
+
+    if filtered_recommendations.empty:
+        logging.info(f"Aucune recommandation à envoyer pour l'utilisateur {user_id} après filtrage.")
+        return None
+
+    # Ordre de préférence des recommandations après la période de découverte
+    if not is_in_discovery_phase(user_id):
+        preference_order = ['UserActivityReengage', 'QualityImprove', 'POIDiscover', 
+                            'CoverageExpand', 'ConcentrationReduce', 
+                            'TextIncentive', 'SymbolEnrich', 'GraphicalExpress']
+        
+        # Créer une colonne de priorité
+        filtered_recommendations['priority'] = filtered_recommendations['strategy'].apply(
+            lambda x: preference_order.index(x) if x in preference_order else float('inf')
+        )
+        
+        # Filtrer les recommandations par priorité
+        filtered_recommendations = filtered_recommendations.sort_values('priority')
+
+    # Sélectionner UNE SEULE recommandation (en prenant la première par exemple après filtrage)
+    selected_recommendation = filtered_recommendations.iloc[0]
+
+    # Mettre à jour le statut de la recommandation sélectionnée à 'Ready'
+    if selected_recommendation['status'] == 'Created':
+        update_recommendation_status(selected_recommendation['id'], 'Ready')
+        notify_user(user_id, selected_recommendation)
+
+    return selected_recommendation
+
+
+
+
+def send_recommendation_notifications():
+    notifiable_users = fetch_users_with_pending_recommendations()
+    if not notifiable_users.empty:
+        for index, user in notifiable_users.iterrows():
+            print('Managing recommendations of user:', user['user_id'])
+            select_recommendations(user['user_id'])
+
+    else:
+        print("Aucun utilisateur trouvé avec des recommandations à analyser.")
diff --git a/rs/modules/preprocessing/__init__.py b/rs/modules/preprocessing/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rs/modules/preprocessing/preprocess_annotations.py b/rs/modules/preprocessing/preprocess_annotations.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb6bec59e68b2e670c46137b506f15c49629bf66
--- /dev/null
+++ b/rs/modules/preprocessing/preprocess_annotations.py
@@ -0,0 +1,177 @@
+import spacy
+import re
+import string
+
+# Charger le modèle de langue française
+nlp = spacy.load('fr_core_news_sm')
+
+MAX_ALLOWED_TAGS = 10
+MAX_ALLOWED_ICONS = 10
+
+def preprocess_text_french(text, keep_punctuation=False):
+    """
+    Prétraite le texte en français en le nettoyant, en supprimant la ponctuation (optionnelle),
+    en le tokenisant, en supprimant les stopwords, et en appliquant la racinisation.
+
+    :param text: Le texte à prétraiter.
+    :param keep_punctuation: Boolean indiquant si la ponctuation doit être conservée.
+    :return: Une liste de phrases prétraitées.
+    """
+    # Nettoyage du texte
+    text = re.sub(r'[^a-zA-ZÀ-ÿ\s]', '', text).lower()
+
+    # Traitement du texte avec spaCy
+    doc = nlp(text)
+
+    # Optionnel: suppression de la ponctuation
+    if not keep_punctuation:
+        tokens = [token.text for token in doc if not token.is_punct and not token.is_space]
+    else:
+        tokens = [token.text for token in doc if not token.is_space]
+
+    # Suppression des stopwords et racinisation
+    tokens = [token.lemma_ for token in doc if not token.is_stop and token.lemma_ != '-PRON-']
+
+    return tokens
+
+
+def calculate_volume_lexical(text):
+    return len(' '.join(preprocess_text_french(text)).split())
+
+def calculate_diversity_lexical(text):
+    return len(set(' '.join(preprocess_text_french(text)).split()))
+
+def calculate_ratio(value, total):
+    return 0.0 if total == 0 else float(value / total)
+
+def calculate_icons_metrics(used_icons, message):
+    if not used_icons:
+        return {key: 0.0 for key in [
+            'diversity_types_icons_used', 'efficiency_icons_used', 'frequency_sensationnel',
+            'activity_dynamics', 'atmosphere_environment', 'emphasis_social', 'resonance_memory'
+        ]}
+
+    ICON_MAPPING = {
+        'Senses': ['senses', 'seasons', 'meteo', "daytime"],
+        'Activity': ['shopping', 'shop', 'trip', 'restaurant', 'culture', 'sport', 'studies', 'ride', 'work', 'health', 'home', 'transport'],
+        'Env': ['eyeview', 'site', 'nature', 'tourism'],
+        'Social': ['date', 'friends', 'meet', 'community', 'social'],
+        'Memory': ['memory', 'homesick', 'heartplace', 'homeheart']
+    }
+
+    icon_counts = {icon: used_icons.count(icon) for icon in set(used_icons)}
+
+    used_icon_types = [icon_type for icon_type, icons in ICON_MAPPING.items() if any(icon in icons for icon in used_icons)]
+    diversity_types_icons_used = calculate_ratio(len(set(used_icon_types)), len(ICON_MAPPING))
+    efficiency_icons_used = calculate_ratio(len(re.findall(r'\w+', message)), len(used_icons))
+
+    # print('used_icons: ', used_icons)
+    # print('computed icon_counts: ', icon_counts)
+
+    return {
+        'diversity_types_icons_used': diversity_types_icons_used,
+        'types_icons_count': len(set(used_icon_types)),
+        'efficiency_icons_used': efficiency_icons_used,
+        'senses_ratio': calculate_ratio(sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Senses']), len(used_icons)),
+        'activity_ratio': calculate_ratio(sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Activity']), len(used_icons)),
+        'environment_ratio': calculate_ratio(sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Env']), len(used_icons)),
+        'social_ratio': calculate_ratio(sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Social']), len(used_icons)),
+        'memory_ratio': calculate_ratio(sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Memory']), len(used_icons))
+    }
+
+def calculate_tags_metrics(used_tags):
+    positive_tags = ['#utile', '#plaisir', '#beau', '#agréable']
+    negative_tags = ['#laid', '#désagréable']
+    aesthetic_tags = ['#beau', '#laid', '#agréable', '#désagréable']
+    social_tags = ['#animé', '#isolé', '#social', '#personnel', '#sur', '#dangereux', '#familier', '#inhabituel']
+
+    total_tags_used = len(used_tags)
+
+    return {
+        'tag_influence_index': calculate_ratio(total_tags_used, MAX_ALLOWED_TAGS),
+        'positive_sentiment_ratio': calculate_ratio(sum(tag in positive_tags for tag in used_tags), total_tags_used),
+        'negative_sentiment_ratio': calculate_ratio(sum(tag in negative_tags for tag in used_tags), total_tags_used),
+        'aesthetic_tag_index': calculate_ratio(sum(tag in aesthetic_tags for tag in used_tags), total_tags_used),
+        'social_interaction_index': calculate_ratio(sum(tag in social_tags for tag in used_tags), total_tags_used)
+    }
+
+def calculate_emoticon_metrics(emoticon):
+    return 1 if emoticon and not emoticon.isspace() else 0
+
+def calculate_graphical_expressiveness(imageId):
+    return 1 if imageId and not imageId.isspace() else 0
+
+def calculate_icons_count(used_icons):
+    ICON_MAPPING = {
+        'Senses': ['senses', 'seasons', 'meteo', "daytime"],
+        'Activity': ['shopping', 'shop', 'trip', 'restaurant', 'culture', 'sport', 'studies', 'ride', 'work', 'health', 'home', 'transport'],
+        'Env': ['eyeview', 'site', 'nature', 'tourism'],
+        'Social': ['date', 'friends', 'meet', 'community', 'social'],
+        'Memory': ['memory', 'homesick', 'heartplace', 'homeheart'],
+        'Mission': ['mission01', 'mission02', 'mission03', 'mission04', 'mission05', 'mission06', 'mission07', 'mission08', 'mission09', 'mission10', 'mission11', 'mission12', 'mission13', 'mission14']
+    }
+
+    icon_counts = {icon: used_icons.count(icon) for icon in set(used_icons)}
+
+    return {
+        'total_icons_count': len(used_icons),
+        'senses_icons_count': sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Senses']),
+        'social_icons_count': sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Social']),
+        'memory_icons_count': sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Memory']),
+        'activity_icons_count': sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Activity']),
+        'environment_icons_count': sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Env']),
+        'mission_icons_count': sum(icon_counts.get(icon, 0) for icon in ICON_MAPPING['Mission'])
+    }
+
+def calculate_tags_count(used_tags):
+    positive_tags = ['#utile', '#plaisir', '#beau', '#agréable']
+    negative_tags = ['#laid', '#désagréable']
+    aesthetic_tags = ['#beau', '#laid', '#agréable', '#désagréable']
+    social_tags = ['#animé', '#isolé', '#social', '#personnel', '#sur', '#dangereux', '#familier', '#inhabituel']
+
+    return {
+        'total_tags_count': len(used_tags),
+        'positive_tags_count': sum(tag in positive_tags for tag in used_tags),
+        'negative_tags_count': sum(tag in negative_tags for tag in used_tags),
+        'aesthetic_tags_count': sum(tag in aesthetic_tags for tag in used_tags),
+        'social_tags_count': sum(tag in social_tags for tag in used_tags)
+    }
+
+def preprocess_annotations(annotations):
+    for doc in annotations:
+        # Récupérer le texte du commentaire, ou une chaîne vide si la colonne 'comment' est absente ou vide
+        annotation_text = doc.get('comment', '')
+
+        # Récupérer les icônes utilisées, ou une liste vide si la colonne 'place_icon' est absente ou vide
+        used_icons = doc.get('placeIcon', '').split(',') if doc.get('placeIcon') else []
+
+        # Récupérer les tags utilisés, ou une liste vide si la colonne 'tags' est absente ou vide
+        used_tags = doc.get('tags', '').split() if doc.get('tags') else []
+
+        # Récupérer l'emoticon, ou une chaîne vide si la colonne 'emoticon' est absente
+        emoticon = doc.get('emoticon', '')
+
+        # Récupérer l'ID de l'image, ou None si la colonne 'image_id' est absente
+        imageId = doc.get('image')
+
+        doc['volume_lexical'] = calculate_volume_lexical(annotation_text)
+        
+        doc['diversity_lexical'] = calculate_diversity_lexical(annotation_text)
+
+        icon_metrics = calculate_icons_metrics(used_icons, annotation_text)
+        doc.update(icon_metrics)
+
+        tags_metrics = calculate_tags_metrics(used_tags)
+        doc.update(tags_metrics)
+
+        doc['emoticonality'] = calculate_emoticon_metrics(emoticon)
+        
+        doc['graphical'] = calculate_graphical_expressiveness(imageId)
+
+        icons_count = calculate_icons_count(used_icons)
+        doc.update(icons_count)
+        tags_count = calculate_tags_count(used_tags)
+        doc.update(tags_count)
+    
+    print("Finished processing annotations.")
+    return annotations
diff --git a/rs/modules/preprocessing/preprocess_behavioral_data.py b/rs/modules/preprocessing/preprocess_behavioral_data.py
new file mode 100644
index 0000000000000000000000000000000000000000..4841d1a1e35bf2fb947cd11404d01bea47e90aa9
--- /dev/null
+++ b/rs/modules/preprocessing/preprocess_behavioral_data.py
@@ -0,0 +1,51 @@
+from elasticsearch import Elasticsearch
+from sqlalchemy import create_engine, MetaData, Table, Column, String
+from sqlalchemy.orm import sessionmaker
+
+def preprocess_behavioral_data(raw_data):
+    preprocessed_data = []
+    for entry in raw_data:
+        # Votre logique de prétraitement ici
+        processed_entry = {
+            'user_id': entry['user_id'],
+            'action': entry['action'],
+            'timestamp': entry['timestamp'],
+            # Ajoutez d'autres champs nécessaires après prétraitement
+        }
+        preprocessed_data.append(processed_entry)
+    return preprocessed_data
+
+def fetch_behavioral_data_from_elasticsearch():
+    es = Elasticsearch(['http://elasticsearch:9200'])
+    result = es.search(index="behavioral_data", body={"query": {"match_all": {}}})
+    return [hit['_source'] for hit in result['hits']['hits']]
+
+def connect_to_local_database():
+    engine = create_engine('mysql+pymysql://mobiles:mobilespassword@localhost/gpstour', echo=False)
+    Session = sessionmaker(bind=engine)
+    return Session()
+
+def save_behavioral_data_to_local_database(behavioral_data):
+    try:
+        db_session = connect_to_local_database()
+
+        metadata = MetaData()
+
+        behavioral_table = Table('behavioral_data', metadata,
+                                 Column('user_id', String(length=255), primary_key=True),
+                                 Column('action', String(length=255)),
+                                 Column('timestamp', String(length=255)),
+                                 extend_existing=True)
+
+        metadata.create_all(db_session.bind, checkfirst=True)
+
+        for data in behavioral_data:
+            db_session.execute(behavioral_table.insert().prefix_with("IGNORE"), data)
+
+        db_session.commit()
+        print("Behavioral data saved to local database successfully.")
+
+    except Exception as e:
+        print(f"Error saving behavioral data to local database: {e}")
+    finally:
+        db_session.close()
diff --git a/rs/modules/preprocessing/utils.py b/rs/modules/preprocessing/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..436e028cc979cd695ad174080cb5c31549e1bc4c
--- /dev/null
+++ b/rs/modules/preprocessing/utils.py
@@ -0,0 +1,9 @@
+ICON_MAPPING = {
+    'Senses': ['senses', 'seasons', 'meteo', "daytime"],
+    'Activity': ['shopping', 'shop', 'trip', 'restaurant', 'culture', 'sport', 'studies', 'ride', 'work', 'health', 'home', 'transport'],
+    'Env': ['eyeview', 'site', 'nature' , 'tourism'],
+    'Social': ['date', 'friends', 'meet' , 'community', 'social'],
+    'Memory': ['memory', 'homesick', 'heartplace', 'homeheart'],
+    'Mission': ['mission01','mission02','mission03','mission04','mission05','mission06','mission07','mission08','mission09',
+                'mission10','mission11','mission12','mission13','mission14']
+}
diff --git a/rs/modules/recommendation_engine.py b/rs/modules/recommendation_engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..f5eb69e9c9f748e1977612e3d9e6582da65ac296
--- /dev/null
+++ b/rs/modules/recommendation_engine.py
@@ -0,0 +1,44 @@
+from .db_operations import connect_to_local_database
+from sqlalchemy import MetaData, Table, Column, Integer, String
+
+def generate_and_save_recommendations():
+    try:
+        db_session = connect_to_local_database()
+
+        metadata = MetaData()
+
+        recommendations_table = Table('recommendations', metadata,
+                                      Column('post_id', Integer, primary_key=True),
+                                      Column('recommendation', String(length=255)),
+                                      extend_existing=True)
+
+        metadata.create_all(db_session.bind, checkfirst=True)
+
+        query = "SELECT post_id, volume_lexical, diversity_lexical FROM indicators"
+        result = db_session.execute(query)
+
+        recommendations = []
+        for row in result:
+            post_id = row['post_id']
+            volume_lexical = row['volume_lexical']
+            diversity_lexical = row['diversity_lexical']
+
+            if volume_lexical > 100:
+                recommendation = f"Post {post_id}: High volume lexical content."
+            elif diversity_lexical < 0.2:
+                recommendation = f"Post {post_id}: Low lexical diversity."
+            else:
+                recommendation = f"Post {post_id}: Keep up the good work!"
+
+            recommendations.append({'post_id': post_id, 'recommendation': recommendation})
+
+        for recommendation in recommendations:
+            db_session.execute(recommendations_table.insert().prefix_with("IGNORE"), recommendation)
+
+        db_session.commit()
+        print("Recommendations generated and saved to database successfully.")
+
+    except Exception as e:
+        print(f"Error generating or saving recommendations: {e}")
+    finally:
+        db_session.close()
diff --git a/rs/modules/recommendations/__init__.py b/rs/modules/recommendations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rs/modules/recommendations/adoption_integration_rec.py b/rs/modules/recommendations/adoption_integration_rec.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b5dc909154fcec1f758645016960b5bbc232fcb
--- /dev/null
+++ b/rs/modules/recommendations/adoption_integration_rec.py
@@ -0,0 +1,246 @@
+from datetime import datetime
+from modules.db_operations import fetch_indicators, get_indicator_value, get_user_registration_date, insert_recommendation
+from modules.es_operations import fetch_user_ids
+from modules.suggester import Suggester
+
+# Définir les seuils pour les recommandations
+# Ces seuils sont basés sur des comportements utilisateur typiques observés pendant la phase de démarrage.
+THRESHOLD_NIA = 10  # Nombre d'actions initiales attendues pour un démarrage satisfaisant
+THRESHOLD_NIS = 3   # Nombre de partages initiaux pour considérer un utilisateur comme engagé
+THRESHOLD_NCA = 2   # Nombre minimum d'annotations créées pour un engagement initial
+THRESHOLD_NC = 1   # Nombre minimum de commenntaires
+THRESHOLD_NR = 1   # Nombre minimum de réactions
+THRESHOLD_NCR = 1   # Nombre de routes créées pour un utilisateur créatif
+THRESHOLD_NDL = 1  # Nombre de lieux découverts
+SUF_THRESHOLD = 0.5  # Seuil de score d'utilisation des fonctionnalités en dessous duquel une recommandation est nécessaire
+
+THRESHOLD_NCOA = 3 # Nombre d'annotations consultés
+
+SUF_CREATION_THRESHOLD = 0.3
+SUF_EXPLORATION_THRESHOLD = 0.5
+SUF_INTERACTION_THRESHOLD = 0.2
+
+from datetime import datetime
+
+def days_since_registration(registration_date, today=None):
+    """
+    Retourne le nombre de jours écoulés depuis la date d'inscription de l'utilisateur.
+    
+    :param registration_date: La date d'inscription de l'utilisateur (objet datetime).
+    :param today: La date actuelle (objet datetime), par défaut, la date du jour.
+    :return: Le nombre de jours écoulés depuis la date d'inscription.
+    """
+    if today is None:
+        today = datetime.today()
+    
+    return (today - registration_date).days
+
+def is_in_discovery_phase(registration_date, threshold=14, today=None):
+    """
+    Vérifie si un utilisateur est nouveau (inscrit il y a moins de `threshold` jours).
+    
+    :param registration_date: La date d'inscription de l'utilisateur (objet datetime).
+    :param threshold: Le nombre de jours qui définit qu'un utilisateur est encore nouveau (par défaut 14 jours).
+    :param today: La date actuelle (objet datetime), par défaut, la date du jour.
+    :return: True si l'utilisateur est inscrit il y a moins de `threshold` jours, False sinon.
+    """
+    days_since = days_since_registration(registration_date, today)
+    return True
+    return days_since < threshold
+
+
+
+def generate_initial_support_recommendations(user_id, indicators, suggester):
+    """
+    Génère des recommandations pour le soutien initial basé sur les indicateurs NIA et NIS.
+    """
+    nia = get_indicator_value(indicators, 'NIA')
+    nis = get_indicator_value(indicators, 'NIS')
+    registration_date_str = get_user_registration_date(user_id)
+    
+    # Convertir la date d'inscription en objet datetime
+    registration_date = datetime.strptime(registration_date_str, '%Y-%m-%d')
+    today = datetime.now()
+
+    # Vérifier si l'utilisateur vient de s'inscrire
+    if (today - registration_date).days <= 3:
+        insert_recommendation(
+            user_id, 
+            "Adoption et Intégration", 
+            "InitSupport",
+            "Bienvenue dans MOBILES !", 
+            "Découvre comment annoter tes expériences de la ville et explorer les récits des autres avec notre guide de démarrage.",
+            'https://mobiles-projet.huma-num.fr/lapplication/tutoriel-mobiles/',
+            "LIEN"
+        )
+    
+    # Si NIA < T1 après 3 jours
+    if nia is not None and (today - registration_date).days > 3  and nia < THRESHOLD_NIA:
+        insert_recommendation(
+            user_id, 
+            "Adoption et Intégration", 
+            "InitSupport",
+            "Explore et Interagis !", 
+            "Tu as déjà fait tes premiers pas, maintenant, il est temps de t'engager pleinement ! Découvre ce que d'autres partagent et ajoute tes propres expériences. Suis notre tutoriel pour apprendre à interagir efficacement",
+            'https://mobiles-projet.huma-num.fr/lapplication/tutoriel-mobiles/',
+            "LIEN"
+        )
+
+    # Si NIA ≥ T1 mais NIS < T2 après 6 jours
+    if nia is not None and nia >= THRESHOLD_NIA and nis is not None and (today - registration_date).days > 6 and nis < THRESHOLD_NIS:
+        insert_recommendation(
+            user_id, 
+            "Adoption et Intégration", 
+            "InitSupport",
+            "Documente et partage tes expériences", 
+            "MOBILES t'offre un espace unique pour rendre compte de tes expériences de la ville et les partager avec les personnes de ton choix. Consulte notre guide pour apprendre comment partager tes récits de manière simple et impactante.",
+            'https://mobiles-projet.huma-num.fr/lapplication/tutoriel-mobiles/',
+            "LIEN"
+        )
+
+
+def generate_feature_usage_recommendations(user_id, indicators, suggester):
+    """
+    Génère des recommandations basées sur les indicateurs pour chaque stratégie.
+    """
+    # Récupération des valeurs des indicateurs
+    nca = get_indicator_value(indicators, 'NCA')
+    ncr = get_indicator_value(indicators, 'NCR')
+    suf_creation = get_indicator_value(indicators, 'SUF_Creation')
+    ndl = get_indicator_value(indicators, 'NDL')
+    ncoa = get_indicator_value(indicators, 'NCoA')
+    suf_exploration = get_indicator_value(indicators, 'SUF_Exploration')
+    nc = get_indicator_value(indicators, 'NC')
+    nr = get_indicator_value(indicators, 'NR')
+    suf_interaction = get_indicator_value(indicators, 'SUF_Interaction')
+
+    
+
+    # Stratégie 2: Fonctionnalités clés – Création et partage
+    
+    if suf_creation != None:
+        handle_creation_and_sharing(user_id, nca, ncr, suf_creation, suggester)
+
+    # Stratégie 3: Fonctionnalités clés – Exploration de Contenu
+    if suf_exploration != None:
+        handle_content_exploration(user_id, ndl, ncoa, suf_exploration, suggester)
+
+    # Stratégie 4: Fonctionnalités clés – Interaction
+    if suf_interaction != None:
+        handle_interaction(user_id, nc, nr, suf_interaction, suggester)
+
+def handle_creation_and_sharing(user_id, nca, ncr, suf_creation, suggester):
+    category = "CAT1-Adoption_Integration"
+    strategy = "FuncCreateShare"
+    suggestion_type ='annotation'
+    strategies = ['rich_annotations','annotations_based_on_interests']
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+
+    if suf_creation < SUF_CREATION_THRESHOLD:
+        # strategies = ['nearby_annotations', 'interest_based_annotations']        
+        suggestion_type ='annotation'
+        title = "Lance toi ! Crée tes premières annotations"
+        content = f"Tu ne sais pas quoi annoter ? Regarde quelques exemples qui pourront t'inspirer."
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type) 
+
+    elif nca < THRESHOLD_NCA:
+        suggestion_type ='annotation'
+        title = "Partage tes découvertes !"
+        content = f"Tu as certainement beaucoup d'expériences à décrire ! Raconte, documente et partage ce que tu sens ou ressens dans les différents lieux que tu fréquentes. Inspire-toi de cette annotation."
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type)
+
+    elif ncr == THRESHOLD_NCR :
+        suggestion_type ='tour'
+        strategies = ['nearby_tours']
+        suggestions = suggester.suggest(strategies)
+        suggestion =suggestions[0].get('id')
+        title = "As tu essayé de décrire des trajets ou des balades ?"
+        content = f"Suis notre tutoriel interactif pour apprendre à créer des  parcours et découvre celui-ci pour t'inspirer."
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type)
+
+
+def handle_content_exploration(user_id, ndl, ncoa, suf_exploration, suggester):
+    category = "CAT1-Adoption_Integration"
+    strategy = "FuncContentExplore"
+    suggestion_type ='annotation'
+    strategies = ['rich_annotations','popular_annotations','annotations_based_on_interests']
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+    if suf_exploration < SUF_EXPLORATION_THRESHOLD:
+        suggestion ='https://mobiles-projet.huma-num.fr/lapplication/tutoriel-mobiles/'
+        suggestion_type ='link'
+        title = "Optimise ton expérience d'exploration !"
+        content = f"Pour découvrir des lieux et contenus qui correspondent vraiment à tes intérêts, essaie nos outils de filtrage et de recherche. Apprends comment maximiser tes explorations et commence dès maintenant à dénicher les trésors cachés de la ville."
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type)
+
+    elif ndl < THRESHOLD_NDL:
+        strategies = ['rich_annotations','uexplored_zone_annotation','popular_annotations']
+        suggestions = suggester.suggest(strategies)
+        suggestion =suggestions[0].get('id')
+        title = "Explore des lieux inédits !"
+        content = f"La ville regorge de surprises ! Il y a tant de lieux fascinants à découvrir. Plonge dans l'exploration en consultant les annotations d'autres utilisateurs sur des endroits que tu n'as pas encore vus. Voici un lieu qui pourrait vraiment te surprendre."
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type)
+
+    elif ncoa < THRESHOLD_NCOA:
+        strategies = ['rich_annotations']
+        suggestions = suggester.suggest(strategies)
+        suggestion =suggestions[0].get('id')
+        title = "Découvre de nouvelles expériences partagées !"
+        content = f"Les annotations de la communauté te dévoilent des histoires et des détails uniques. Enrichis ton exploration en découvrant les lieux sous un nouvel angle, grâce aux expériences des autres. Pour commencer, voici une annotation qui pourrait t'inspirer."
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type)
+
+
+def handle_interaction(user_id, nc, nr, suf_interaction, suggester):
+    category = "CAT1-Adoption_Integration"
+    strategy = "FuncInteraction"
+    suggestion_type ='annotation'
+    strategies = ['rich_annotations', 'annotations_based_on_reactions']
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+
+    if suf_interaction < SUF_INTERACTION_THRESHOLD:
+        title = "Fais entendre ta voix !"
+        content = f"Tes réactions et commentaires enrichissent le contenu partagé et contribuent à faire progresser les expériences de la ville. Chaque interaction apporte de nouvelles perspectives et valorise les contributions des autres. Découvre comment interagir efficacement grâce à notre guide, et fais le premier pas avec cette annotation."
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type)
+
+    elif nc < THRESHOLD_NC:
+        title = "Participe aux discussions !"
+        content = f"Les commentaires rendent les échanges plus vivants et intéressants. Apprends comment commenter efficacement en suivant notre tutoriel. Rejoins la conversation autour de cette annotation très commentée."
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type)
+
+    elif nr < THRESHOLD_NR:
+        title = "Réagis aux contributions !"
+        content = f"Les réactions sont un moyen simple et puissant de montrer ce que tu ressens face aux annotations des autres. Chaque réaction contribue à l'échange et à la dynamique de la communauté. Voici une annotation qui a déjà touché de nombreux utilisateurs. Réagis, et explore notre guide pour découvrir comment enrichir tes interactions !"
+        insert_recommendation(user_id, category, strategy, title, content, suggestion, suggestion_type)
+
+
+
+def generate_adoption_integration_recommendations():
+    """
+    Génère des recommandations pour une liste d'utilisateurs, uniquement pour ceux inscrits depuis moins de 2 semaines.
+    """
+    user_ids = fetch_user_ids()
+    
+    for user_id in user_ids:
+        # Récupérer la date d'inscription de l'utilisateur
+        registration_date_str = get_user_registration_date(user_id)
+        registration_date = datetime.strptime(registration_date_str, '%Y-%m-%d')
+        
+        # Vérifier si l'utilisateur est nouveau (inscrit depuis moins de 2 semaines)
+        if not is_in_discovery_phase(registration_date):
+            print(f"L'utilisateur {user_id} n'est plus nouveau, aucune recommandation générée.")
+            continue  # Passer à l'utilisateur suivant s'il n'est pas nouveau
+        else:
+            # Récupérer les indicateurs pour l'utilisateur
+            indicators = fetch_indicators(user_id)
+            # print('- User ID: ',user_id, ' \n - indicators: ', indicators)
+            
+            # Générer des recommandations pour chaque stratégie uniquement si des indicateurs sont présents
+            if indicators is not None and len(indicators) > 0:
+                suggester = Suggester(user_id=user_id)
+                generate_initial_support_recommendations(user_id, indicators, suggester)
+                if days_since_registration(registration_date) > 7:
+                    generate_feature_usage_recommendations(user_id, indicators, suggester)
+            else:
+                print('No indicators found for user:', user_id)
\ No newline at end of file
diff --git a/rs/modules/recommendations/content_quality_rec.py b/rs/modules/recommendations/content_quality_rec.py
new file mode 100644
index 0000000000000000000000000000000000000000..9757a6fd19082486fd828ca2329bfa69d8e2a253
--- /dev/null
+++ b/rs/modules/recommendations/content_quality_rec.py
@@ -0,0 +1,322 @@
+import pandas as pd
+import ast
+import seaborn as sns
+import matplotlib.pyplot as plt
+from elasticsearch import Elasticsearch
+from elasticsearch.helpers import scan
+from sklearn.model_selection import train_test_split
+from sklearn.linear_model import LinearRegression
+from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error
+from sklearn.compose import ColumnTransformer
+from sklearn.preprocessing import OneHotEncoder, StandardScaler, MultiLabelBinarizer
+from sklearn.pipeline import Pipeline
+from sklearn.ensemble import RandomForestRegressor
+from sklearn.cluster import KMeans
+from sklearn.decomposition import PCA
+import nltk
+from nltk.corpus import stopwords
+from nltk.tokenize import word_tokenize
+from sklearn.feature_extraction.text import CountVectorizer
+from pandas import json_normalize
+from scipy import stats
+from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Float, JSON, inspect, func, MetaData, Table
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker, scoped_session
+from sqlalchemy.types import Float
+from sqlalchemy.orm.exc import NoResultFound
+from datetime import datetime
+import numpy as np
+from shapely.geometry import Point, MultiPoint, Polygon
+from sklearn.cluster import DBSCAN
+import geopandas as gpd
+import math
+import warnings
+import json
+import math
+from collections import Counter
+import requests
+import json
+from datetime import datetime
+from sqlalchemy.exc import SQLAlchemyError
+from collections import Counter
+import warnings
+
+from modules.suggester import Suggester
+from modules.db_operations import fetch_indicators, get_indicator_value, insert_recommendation
+from modules.es_operations import fetch_user_ids
+
+
+# Seuils d'exemple
+SQPA_THRESHOLD = 50  # Score de qualité de profil d'annotation (sur 100)
+SPA_THRESHOLD = 30   # Score de profondeur d'annotation (sur 100)
+VL_THRESHOLD = 200   # Volume lexical (nombre de mots minimum)
+DL_THRESHOLD = 50    # Diversité lexicale (indice de diversité)
+IDI_THRESHOLD = 3    # Diversité des icônes (nombre minimum de types d'icônes différents)
+IFI_THRESHOLD = 5    # Fréquence d'utilisation des icônes (nombre total d'icônes minimum)
+IRT_THRESHOLD = 5    # Richesse des tags (nombre de tags différents minimum)
+UE_THRESHOLD = 2     # Utilisation d'émoticônes (nombre minimum d'émoticônes)
+TI_THRESHOLD = 1     # Taux d'utilisation d'image (au moins 1 image par annotation)
+
+
+# Désactiver tous les avertissements
+warnings.filterwarnings("ignore")
+
+import logging
+# Configurer le niveau de journalisation pour ne montrer que les erreurs et les avertissements
+logging.basicConfig(level=logging.ERROR)
+for logger_name in ['sqlalchemy', 'sqlalchemy.engine']:
+    logger = logging.getLogger(logger_name)
+    logger.setLevel(logging.ERROR)
+    logger.propagate = False
+
+
+nltk.download('stopwords')
+nltk.download('punkt')
+
+stop_words = set(stopwords.words('french'))
+
+
+def get_annotation_location(df_traces, df_recommendations, df_indicator,annotation_id):
+    annotation = df_traces[df_traces['post_id'] == annotation_id].iloc[0]
+    return {'lon': annotation['position.lon'], 'lat': annotation['position.lat']}
+
+def calculate_geo_distance(place1, place2):
+    # print(place1)
+    lon1, lat1 = place1['lon'], place1['lat']
+    lon2, lat2 = place2['lon'], place2['lat']
+
+    # Haversine formula
+    R = 6371  # Radius of Earth in kilometers
+    dlon = math.radians(lon2 - lon1)
+    dlat = math.radians(lat2 - lat1)
+    a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
+    c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
+    distance = R * c
+
+    return distance
+def get_sorted_place_types(user_annotations):
+    place_types = user_annotations['placeType'].tolist()
+    counter = Counter(place_types)
+    sorted_place_types = [pair[0] for pair in counter.most_common()]
+    return sorted_place_types
+
+
+
+def get_taux_utilisation_value(df_indicator, user_id, taux_type):
+    user_condition = (df_indicator['user_id'] == user_id)
+    type_condition = (df_indicator['type'] == taux_type)
+
+    if df_indicator.loc[user_condition & type_condition].empty:
+        print(f"Aucune ligne correspondante pour user_id={user_id} et taux_type={taux_type}")
+        return 0
+
+    return df_indicator.loc[user_condition & type_condition, 'value'].iloc[0]
+
+
+
+
+def build_clusters(annotations, n_clusters=5):
+    # Assuming 'position.lon' and 'position.lat' are separate columns
+    X = annotations[['position.lon', 'position.lat']]
+    
+    # Assuming 'position.lon' and 'position.lat' are in degrees, converting to radians
+    X['position.lon_radians'] = np.radians(X['position.lon'])
+    X['position.lat_radians'] = np.radians(X['position.lat'])
+    
+    # Dropping the original lon and lat columns
+    X = X.drop(['position.lon', 'position.lat'], axis=1)
+
+    kmeans = KMeans(n_clusters=n_clusters, random_state=42).fit(X)
+    annotations['cluster_id'] = kmeans.labels_
+    
+    return annotations
+
+
+def get_cluster_annotations(df_traces, df_recommendations, df_indicator,cluster_id):
+    # Replace this with the function to get annotations in a specific cluster
+    return df_traces[df_traces['cluster_id'] == cluster_id].to_dict(orient='records')
+
+def calculate_thresholds(df_traces, df_recommendations, df_indicator,):
+    # Calculate the thresholds dynamically based on the statistics of annotations
+    seuil_masstextuelle = df_traces['volume_lexical'].quantile(0.7)
+    seuil_diversitetextuelle = df_traces['diversity_lexical'].quantile(0.5)
+    seuil_positive_sentiment_ratio = df_traces['positive_sentiment_ratio'].quantile(0.5)
+
+    return seuil_masstextuelle, seuil_diversitetextuelle, seuil_positive_sentiment_ratio
+
+
+
+## # Reco texts
+reco_textual = {'title': 'Affine tes commentaires', 'content' : 'Enrichis tes annotations avec des détails pour les rendre plus percutantes et informatives. ', 'category':'Recommendation'}
+reco_emo = {'title':'Exprime tes émotions', 'content' : 'Anime tes annotations avec des récits personnels et des icônes émotionnelles, insufflant ainsi vie à tes émotions et souvenirs. ', 'category':'Recommendation'}
+reco_expr_activ = {'title':'Diversifie tes expressions', 'content' : 'Décris-nous une de tes activités, anime chaque moment avec des descriptions détaillées et des icônes pertinentes. ', 'category':'Recommendation'}
+reco_expr_emo = {'title':'Diversifie tes expressions', 'content' : 'Découvre un lieu unique, partage-le avec des icônes évocatrices, et plonge-nous dans ton expérience immersive. ', 'category':'Recommendation'}
+reco_expr_sens = {'title':'Diversifie tes expressions', 'content' : 'Explore une expérience sensorielle, traduis les couleurs, les odeurs, les sons avec des mots et des icônes. ', 'category':'Recommendation'}
+reco_expr_soc = {'title':'Diversifie tes expressions', 'content' : 'Partage tes expériences sociales avec des récits détaillés, enrichis-les avec des icônes sociales pour une touche visuelle engageante. ', 'category':'Recommendation'}
+reco_expr_aff = {'title':'Diversifie tes expressions', 'content' : 'Partage tes moments affectifs avec des descriptions détaillées et ajoute une touche visuelle émouvante avec des icônes affectives. ', 'category':'Recommendation'}
+reco_graphical = {'title':'Capture l\'instant', 'content' : 'Enrichis tes annotations avec des photos captivantes, offrant une plongée visuelle dans l\'essence de tes expériences inoubliables. ', 'category':'Recommendation'}
+reco_sp_div = {'title':'Enrichis tes découvertes', 'content' : 'Élargis tes horizons géographiques en explorant une diversité de lieux et en découvrant de nouveaux territoires pour enrichir tes aventures. ', 'category':'Recommendation'}
+reco_clust = {'title':'Equilibre tes explorations', 'content' : "Pour enrichir ta découverte de Lyon, maintiens un équilibre entre proximité et éloignement, en explorant des endroits proches et d'autres plus éloignés les uns des autres. ", 'category':'Recommendation'}
+
+
+
+
+#  NEW VERSION
+def get_user_indicators(user_id):
+    indicators = fetch_indicators(user_id)
+    q_indicators = {
+        'SQPA': get_indicator_value(indicators, 'SQPA'),
+        'SPA': get_indicator_value(indicators, 'SPA'),
+        'VL': get_indicator_value(indicators, 'VL'),
+        'DL': get_indicator_value(indicators, 'DL'),
+        'IDI': get_indicator_value(indicators, 'IDI'),
+        'IFI': get_indicator_value(indicators, 'IFI'),
+        'IRT': get_indicator_value(indicators, 'IRT'),
+        'UE': get_indicator_value(indicators, 'UE'),
+        'TI': get_indicator_value(indicators, 'TI'),
+    }
+    return q_indicators
+
+
+
+def recommend_quality_improvement(indicators, user_id, suggester):
+    if indicators['SQPA'] < SQPA_THRESHOLD and indicators['SPA'] > SPA_THRESHOLD:
+        strategies = ['annotations_based_on_interests']
+        suggestion_type ='annotation'
+        suggestions = suggester.suggest(strategies)
+        suggestion =suggestions[0].get('id')
+        # Enrichissement Général
+        insert_recommendation(
+            user_id,
+            "CAT3-Quality",
+            "QualityImprove",
+            "Enrichissement Général",
+            "Enrichis ton annotation avec plus de détails ! Ta [ANNOTATION] est déjà utile ! Pourquoi ne pas ajouter quelques anecdotes ou précisions pour la rendre encore plus complète et précieuse pour la communauté ?",
+            suggestion,
+            suggestion_type
+        )
+        # Ajoute d'autres recommandations si nécessaire...
+
+def recommend_text_expression(indicators, user_id, suggester):
+    if indicators['VL'] < VL_THRESHOLD and indicators['DL'] < DL_THRESHOLD:
+        strategies = ['annotations_based_on_interests']
+        suggestion_type ='annotation'
+        suggestions = suggester.suggest(strategies)
+        suggestion =suggestions[0].get('id')
+        insert_recommendation(
+            user_id,
+            "CAT3-Quality",
+            "TextIncentive",
+            "Enrichis Améliore tes textes descriptions textuelles !",
+            "Des annotations détaillées aident à mieux transmettre l'expérience. Enrichis tes contributions en ajoutant plus de précisions et de contexte.",
+            suggestion,
+            suggestion_type
+        )
+
+def recommend_symbolic_enrichment(indicators, user_id, suggester):
+    below_threshold_count = 0
+    last_below_threshold = None
+
+    # Vérification des indicateurs sous le seuil
+    if indicators['IDI'] < IDI_THRESHOLD:
+        below_threshold_count += 1
+        last_below_threshold = 'IDI'
+        
+    if indicators['IFI'] < IFI_THRESHOLD:
+        below_threshold_count += 1
+        last_below_threshold = 'IFI'
+        
+    if indicators['IRT'] < IRT_THRESHOLD:
+        below_threshold_count += 1
+        last_below_threshold = 'IRT'
+        
+    if indicators['UE'] < UE_THRESHOLD:
+        below_threshold_count += 1
+        last_below_threshold = 'UE'
+    
+    strategies = ['annotations_based_on_interests']
+    suggestion_type ='annotation'
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+    # Si plus de deux indicateurs sont sous les seuils -> Recommandation générale
+    if below_threshold_count > 2:   
+        insert_recommendation(
+            user_id,
+            "CAT3-Quality",
+            "SymbolEnrich",
+            "Améliore tes annotations avec des icônes symboles et mots-clés en utilisant toutes les formes d'expression!",
+            "Tes annotations gagneraient à intégrer plus de symboles. Ajoute des d'icônes, de tags ou d'émoticônes pour enrichir ton contenu. Découvre l'annotation [ANNOTATION], un bon exemple pour t'inspirer.",
+            suggestion,
+            suggestion_type
+        )
+    # Sinon, envoie la recommandation spécifique pour l'indicateur en dessous du seuil
+    else:
+        if last_below_threshold == 'IDI':
+            insert_recommendation(
+                user_id,
+                "CAT3-Quality",
+                "SymbolEnrich-IconDiversity",
+                "Varie les icônes dans tes annotations !",
+                "Ta sélection d'icônes est limitée. Ajoute différents types d'icônes pour illustrer tes annotations. Cela apportera plus de richesse visuelle.",
+                suggestion,
+                suggestion_type
+            )
+        elif last_below_threshold == 'IFI':
+            insert_recommendation(
+                user_id,
+                "CAT3-Quality",
+                "SymbolEnrich",
+                "Augmente l'impact de tes annotations avec plus d'icônes !",
+                "Utilise davantage d'icônes pour améliorer la clarté et l'attrait de tes contributions.",
+                suggestion,
+                suggestion_type
+            )
+        elif last_below_threshold == 'IRT':
+            insert_recommendation(
+                user_id,
+                "CAT3-Quality",
+                "SymbolEnrich",
+                "Augmente la visibilité de tes annotations avec des tags !",
+                "Ajoute des tags supplémentaires pour mieux catégoriser ton contenu et le rendre plus facile à découvrir.",
+                suggestion,
+                suggestion_type
+            )
+        elif last_below_threshold == 'UE':
+            insert_recommendation(
+                user_id,
+                "CAT3-Quality",
+                "SymbolEnrich",
+                "Ajoute une touche émotionnelle avec une émoticône !",
+                "En intégrant des émoticônes, tu rends tes annotations plus personnelles. Humanises tes annotations et les rends plus engageantes.",
+                suggestion,
+                suggestion_type
+            )
+
+
+def recommend_graphic_expression(indicators, user_id, suggester):
+    if indicators['TI'] < TI_THRESHOLD:
+        strategies = ['annotations_based_on_interests']
+        suggestion_type ='annotation'
+        suggestions = suggester.suggest(strategies)
+        suggestion =suggestions[0].get('id')
+        insert_recommendation(
+            user_id,
+            "CAT3-Quality",
+            "GraphicalExpress",
+            "Intègre des images pour enrichir tes récits d'expérience !",
+            "Les annotations accompagnées de photos et d'images racontent une histoire plus vivante et captivante. En ajoutant des visuels, tu peux mieux exprimer tes émotions et partager ton expérience.",
+            suggestion,
+            suggestion_type
+        )
+
+def generate_content_quality_recommendations():
+    user_ids = fetch_user_ids()
+    for user_id in user_ids:
+        suggester = Suggester(user_id=user_id)
+        indicators = get_user_indicators(user_id)
+        if all(value < seuil for value, seuil in zip(indicators.values(), [SQPA_THRESHOLD, SPA_THRESHOLD, VL_THRESHOLD, DL_THRESHOLD, IDI_THRESHOLD, IFI_THRESHOLD, IRT_THRESHOLD, UE_THRESHOLD, TI_THRESHOLD])):
+            recommend_quality_improvement(indicators, user_id, suggester)
+        else:
+            recommend_text_expression(indicators, user_id, suggester)
+            recommend_symbolic_enrichment(indicators, user_id, suggester)
+            recommend_graphic_expression(indicators, user_id, suggester)
\ No newline at end of file
diff --git a/rs/modules/recommendations/engagement_reengagement_rec.py b/rs/modules/recommendations/engagement_reengagement_rec.py
new file mode 100644
index 0000000000000000000000000000000000000000..736be427b943c12fb85d5b1451ccafcf56888257
--- /dev/null
+++ b/rs/modules/recommendations/engagement_reengagement_rec.py
@@ -0,0 +1,857 @@
+from collections import defaultdict
+import json
+from modules.db_operations import fetch_annotations_by_id, fetch_new_annotations, fetch_tours, fetch_user_tours, fetch_user_annotations, fetch_indicators, get_indicator_value, insert_recommendation
+from modules.es_operations import fetch_annotation_popularity, fetch_user_ids, fetch_user_locations, fetch_user_logs, fetch_user_viewed_annotations
+from modules.db_operations import fetch_annotations,  insert_recommendation
+import pandas as pd
+
+from modules.metrics.engagement_reengagement_m import THRESHOLD_OA, calculate_min_distance_to_tour
+from modules.suggester import Suggester
+
+# Définir les seuils pour les recommandations
+ENGAGED_THRESHOLD = 0.7 
+DISENGAGED_THRESHOLD = 0.3
+THRESHOLD_PLA = 5  # Seuil de distance moyenne (en kilomètres)
+THRESHOLD_TEL = 0.5  # Seuil du taux d'exploration (50% ou autre valeur appropriée)
+AIR_THRESHOLD = 5 
+AUA_THRESHOLD_VALUE = 0.5
+AUN_THRESHOLD_VALUE = 0.5
+SEA_THRESHOLD = 0
+SEP_THRESHOLD = 0
+
+THRESHOLD_PPLA = 0.5
+THRESHOLD_SPP = 0.5
+
+## Stratégie 1 : Maintien de l'Engagement Basé sur le Profil d'Annotation
+def generate_annotation_recommendations(user_id, indicators, suggester):
+    """
+    Génère des recommandations d'annotations pour un utilisateur donné en fonction de son profil 
+    d'activité (création ou consultation d'annotations).
+    
+    :param user_id: ID de l'utilisateur.
+    :param indicators: DataFrame contenant les indicateurs de l'utilisateur.
+    """
+
+    # Récupérer les indicateurs pour la proportion des activités de création et de consultation d'annotations
+    pca = get_indicator_value(indicators, 'WPCA')  # Création d'Annotations
+    pcoa = get_indicator_value(indicators, 'WPCOA')  # Consultation d'Annotations
+    
+    # Vérifier si les indicateurs sont présents et valides
+    print('pca: ', pca)
+    if pca is None or pcoa is None :
+        print(f"Indicateurs manquants pour l'utilisateur {user_id}")
+        return
+
+    strategies = ['annotations_based_on_interests']
+    suggestion_type ='annotation'
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+    # Axée création : PCA > 0.7
+    if pca > ENGAGED_THRESHOLD:
+        insert_recommendation(
+            user_id,
+            "Engagement",
+            "AnnotationProfileEngage",
+            "Continue d'annoter !",
+            "Tu es très actif dans la création d'annotations, bravo ! Pour t'aider à diversifier tes contributions, voici une annotation intéressante qui pourrait t'inspirer. Continue à enrichir la communauté !",
+            suggestion,
+            suggestion_type
+        )
+
+    # Axée consultation : PCOA > 0.7
+    elif pcoa > ENGAGED_THRESHOLD:
+        insert_recommendation(
+            user_id,
+            "Engagement",
+            "AnnotationProfileEngage",
+            "Découvre et participe !",
+            "Tu consultes régulièrement des annotations, c'est super pour découvrir de nouveaux contenus ! Pourquoi ne pas aller plus loin ? Jette un œil à cette annotation pour t'inspirer et commence à annoter toi-même. Partage tes idées avec la communauté !",
+            suggestion,
+            suggestion_type
+        )
+
+    # Comportement mixte : 0.3 < PCA < 0.7 et 0.3 < PCOA < 0.7
+    elif DISENGAGED_THRESHOLD < pca < ENGAGED_THRESHOLD and DISENGAGED_THRESHOLD < pcoa < ENGAGED_THRESHOLD:
+        insert_recommendation(
+            user_id,
+            "Engagement",
+            "AnnotationProfileEngage",
+            "Explore et crée !",
+            "Tu es actif dans tes contributions et tes découvertes. Voici cette annotation qui pourrait t'inspirer, que ce soit pour créer ou explorer davantage.",
+            suggestion,
+            suggestion_type
+        )
+
+## Stratégie 2 : Maintien de l'Engagement Basé sur la Proximité des Lieux Annotés
+def generate_location_based_recommendations(user_id, indicators, suggester):
+    """
+    Génère des recommandations basées sur la proximité des lieux annotés pour un utilisateur donné 
+    en fonction des indicateurs de proximité et de similarité.
+    
+    :param user_id: ID de l'utilisateur.
+    :param indicators: DataFrame contenant les indicateurs de l'utilisateur.
+    """
+
+    # Récupérer les indicateurs pour la proximité des lieux annotés et la similarité pondérée des profils
+    ppla = get_indicator_value(indicators, 'PLA')  # Proximité des Lieux Annotés
+    
+    # Vérifier si les indicateurs sont présents et valides
+    if ppla is None :
+        print(f"Indicateurs manquants pour l'utilisateur {user_id}")
+        return
+    
+    strategies = ['annotations_based_on_interests']
+    suggestion_type ='annotation'
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+
+    # Proximité des lieux annotés : PPLA < seuil (par exemple 2 km)
+    if ppla < THRESHOLD_PPLA:  # THRESHOLD_PPLA est le seuil défini (par ex. 2 km)
+        insert_recommendation(
+            user_id,
+            "Engagement",
+            "AnnotationLocationEngage",
+            "Des trésors près des lieux que tu fréquentes !",
+            "Il y a des lieux intéressants tout près de tes explorations ! L'endroit décrit dans l'annotation suivante est juste à côté de tes lieux préférés. Explore-le et découvre une nouvelle perspective locale.",
+            suggestion,
+            suggestion_type
+        )
+
+
+# Stratégie 3 : suggestion de parcours basée profil de parcours
+def generate_route_based_recommendations(user_id, indicators, suggester):
+    """
+    Génère des recommandations de parcours basées sur les habitudes de création et de consultation de parcours 
+    pour un utilisateur donné, en fonction des indicateurs de création et de consultation de parcours.
+
+    :param user_id: ID de l'utilisateur.
+    :param indicators: DataFrame contenant les indicateurs de l'utilisateur.
+    """
+
+    # Récupérer les indicateurs pour la création et la consultation de parcours
+    w_pcp = get_indicator_value(indicators, 'WPCP')  # Création de Parcours
+    w_pcop = get_indicator_value(indicators, 'WPCOP')  # Consultation de Parcours
+    print('w_pcp: ',w_pcp)
+    print('w_pcop: ',w_pcop)
+
+    # Vérifier si les indicateurs sont présents et valides
+    if w_pcp is None or w_pcop is None:
+        print(f"Indicateurs WPCP ou WPCoP manquants pour l'utilisateur {user_id}")
+        return
+    
+    strategies = ['annotations_based_on_interests']
+    suggestion_type ='annotation'
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+
+    # Axée Création : w_PCP > 0.7
+    if w_pcp > ENGAGED_THRESHOLD:
+        insert_recommendation(
+            user_id,
+            "Engagement",
+            "RouteSuggest",
+            "Crée encore plus de parcours captivants !",
+            "Continue de partager tes explorations en créant des parcours uniques. Pour t'inspirer, voici un parcours qui pourrait te donner de nouvelles idées.",
+            suggestion,
+            suggestion_type
+        )
+
+    # Axée Consultation : w_PCoP > 0.7
+    elif w_pcop > ENGAGED_THRESHOLD:
+        insert_recommendation(
+            user_id,
+            "Engagement",
+            "RouteSuggest",
+            "Lance-toi dans la création de tes propres parcours !",
+            "Tu as déjà exploré pas mal de parcours, pourquoi ne pas créer le tien ? Pour t'aider à démarrer, découvre ce parcours inspirant.",
+            suggestion,
+            suggestion_type
+        )
+
+    # Mixte : 0.3 < w_PCP < 0.7 et 0.3 < w_PCoP < 0.7
+    elif DISENGAGED_THRESHOLD < w_pcp < ENGAGED_THRESHOLD and DISENGAGED_THRESHOLD < w_pcop < ENGAGED_THRESHOLD:
+        insert_recommendation(
+            user_id,
+            "Engagement",
+            "RouteSuggest",
+            "Combine tes talents d'exploration et de création !",
+            "Profite de tes découvertes pour enrichir tes créations. Voici un parcours qui pourrait parfaitement te correspondre et t'inspirer.",
+            suggestion,
+            suggestion_type
+        )
+
+# Stratégie 4 : Réengagement à travers les Interactions Utilisateur
+def generate_reengagement_recommendations(user_id, indicators, suggester):
+    """
+    Génère des recommandations de réengagement pour un utilisateur basé sur son interaction avec la plateforme.
+    
+    :param user_id: ID de l'utilisateur.
+    :param indicators: DataFrame contenant les indicateurs d'engagement de l'utilisateur.
+    """
+
+    # Récupérer les indicateurs de désengagement (ID) et les scores d'engagement dans différentes fonctionnalités
+    disengagement_index = get_indicator_value(indicators, 'ID')  # Indicateur de Désengagement
+    suf_creation = get_indicator_value(indicators, 'SUF_Creation')  # Engagement dans la création
+    suf_interaction = get_indicator_value(indicators, 'SUF_Interaction')  # Engagement dans les interactions (commentaires, réactions)
+    suf_exploration = get_indicator_value(indicators, 'SUF_Exploration')  # Engagement dans l'exploration (annotations, parcours)
+
+    # Définir le seuil de désengagement
+   
+    # Vérifier si les indicateurs sont présents et valides
+    if disengagement_index is None or suf_creation is None or suf_interaction is None or suf_exploration is None:
+        print(f"Indicateurs manquants pour l'utilisateur {user_id}")
+        return
+
+    recommendations = []
+
+    strategies = ['annotations_based_on_interests']
+    suggestion_type ='annotation'
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+
+    # Si ID et SUF_Création < seuil défini
+    if disengagement_index < DISENGAGED_THRESHOLD and suf_creation < DISENGAGED_THRESHOLD:
+        recommendations.append({
+            'strategy': 'UserCreationReengage',
+            "titre": "Pourquoi ne pas explorer d’avantage?!",
+            "contenu": "Tu as encore plein de potentiel à exploiter dans la création d’annotations et de parcours. Pourquoi ne pas essayer de créer une nouvelle annotation aujourd’hui ? Cela enrichira ton expérience et te permettra de partager ce que tu vis avec la communauté.",
+            'suggestion': suggestion,
+            'suggestion_type': suggestion_type
+        })
+
+    
+
+    # Si ID et SUF_Exploration < seuil défini
+    if disengagement_index < DISENGAGED_THRESHOLD and suf_exploration < DISENGAGED_THRESHOLD:
+        recommendations.append({
+            'strategy': 'UserExplorationReengage',
+            "titre": "Découvres de nouvelles annotations et de nouveaux parcours",
+            "contenu": "Sais-tu que d’autres utilisateurs créent régulièrement des annotations et des parcours qui pourraient t’intéresser ? Prends le temps d’explorer ce que les autres partagent et inspire-toi-en.",
+            'suggestion': suggestion,
+            'suggestion_type': suggestion_type
+        })
+
+    # Si ID et SUF_Interaction < seuil défini
+    if disengagement_index < DISENGAGED_THRESHOLD and suf_interaction < DISENGAGED_THRESHOLD:
+        recommendations.append({
+            'strategy': 'UserInteractionReengage',
+            "titre": "Participe à la conversation !",
+            "contenu": "Tu as beaucoup à apporter aux discussions. Tes idées et tes retours sont précieux pour l’expérience des autres utilisateurs et pour la tienne. N’hésite pas à réagir au contenu des autres !",
+            'suggestion': suggestion,
+            'suggestion_type': suggestion_type
+        })
+
+    # Si plus d'un indicateur est en dessous du seuil, ajouter une recommandation générale
+    if len(recommendations) > 1:
+        rec = {
+            'strategy': 'UserActivityReengage',
+            "titre": "Maximise ton expérience !",
+            "contenu": "Tu peux découvrir tout ce que notre application a à offrir en suivant notre tutoriel. Ce guide te présentera toutes les fonctionnalités et t’aidera à tirer le meilleur parti de cette application.",
+            'suggestion': suggestion,
+            'suggestion_type': suggestion_type
+        }
+        insert_recommendation(user_id, "CAT2-Engagement", rec["strategy"], rec["titre"], rec["contenu"], rec['suggestion'], rec['suggestion_type'])
+    else:        
+        # Insérer les recommandations dans la base de données
+        for rec in recommendations:
+            insert_recommendation(user_id, "CAT2-Engagement", rec["strategy"], rec["titre"], rec["contenu"], rec['suggestion'], rec['suggestion_type'])
+
+# Stratégie 5 : Réengagement Basé sur l'Historique des Activités
+def generate_history_based_reengagement_recommendations(user_id, indicators, suggester):
+    """
+    Génère des recommandations de réengagement basées sur l'historique d'activités de l'utilisateur.
+    
+    :param user_id: ID de l'utilisateur.
+    :param activity_history: DataFrame contenant l'historique des activités de l'utilisateur.
+    """
+
+    # Récupérer les indicateurs SEA (Annotations) et SEP (Parcours)
+    sea = get_indicator_value(indicators, 'SEA')  # Historique d'annotations (évolution)
+    sep = get_indicator_value(indicators, 'SEP')  # Historique de parcours (évolution)
+
+    # Vérifier si les indicateurs sont présents et valides
+    if sea is None or sep is None:
+        print(f"Indicateurs manquants pour l'utilisateur {user_id}")
+        return
+
+    recommendations = []
+    strategies = ['annotations_based_on_interests']
+    suggestion_type ='annotation'
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+
+    # Si SEA (annotations) <= 0, proposer une réactivation centrée sur les annotations
+    if sea <= SEA_THRESHOLD:
+        recommendations.append({
+            "strategy": "AnnotationHistoryReengage",
+            "titre": "Reviens partager de nouvelles expériences",
+            "contenu": "Reviens et partage tes expériences en annotant ! Jette un œil à l'annotation suivante, un exemple !=> un exemple que tu vas probablement aimer.",
+            'suggestion': suggestion,
+            'suggestion_type':suggestion_type
+        })
+
+    # Si SEP (parcours) <= 0, proposer une réactivation centrée sur les parcours
+    if sep <= SEP_THRESHOLD:
+        recommendations.append({
+            "strategy": "TourHistoryReengage",
+            "titre": "Reviens partager tes parcours !",
+            "contenu": "Relance-toi dans la création de tes trajets ! Découvre le parcours suivant, un exemple qui devrait t’intéresser.",
+            'suggestion': suggestion,
+            'suggestion_type':suggestion_type
+        })
+
+    # Insérer les recommandations dans la base de données
+    for rec in recommendations:
+        insert_recommendation(user_id, "CAT2-Engagement", rec["strategy"], rec["titre"], rec["contenu"], rec['suggestion'], rec['suggestion_type'])
+
+
+# Stratégie 6 : Réengagement à Travers le Contenu de la Communauté
+def generate_community_content_recommendations(user_id, indicators, suggester):
+    """
+    Génère des recommandations de réengagement basées sur l'interaction de l'utilisateur avec le contenu de la communauté.
+    
+    :param user_id: ID de l'utilisateur.
+    :param indicators: DataFrame contenant les indicateurs d'engagement de l'utilisateur.
+    """
+
+    # Récupérer les scores d'engagement de l'utilisateur
+    sca = get_indicator_value(indicators, 'SCA')  # Score de Consultation des Annotations
+    scp = get_indicator_value(indicators, 'SCP')  # Score de Consultation des Parcours
+    sis = get_indicator_value(indicators, 'SIS')  # Score d'Interaction Social
+
+    # Définir les seuils pour l'engagement (par exemple, ici inférieur à 0.3 indique un besoin de réengagement)
+    
+
+    recommendations = []
+    strategies = ['annotations_based_on_interests']
+    suggestion_type ='annotation'
+    suggestions = suggester.suggest(strategies)
+    suggestion =suggestions[0].get('id')
+
+    print('sca: ',sca, '- scp: ',scp,' - sis: ', sis)
+
+    # Si SCA < seuil défini
+    if sca is not None and sca < DISENGAGED_THRESHOLD:
+        recommendations.append({
+            "titre": "Explore les annotations les plus appréciées !",
+            "contenu": "Découvre l'annotation suivante, qui a généré de nombreuses réactions. Elle pourrait enrichir ton expérience et correspondre à tes intérêts !",
+            'suggestion': suggestion,
+            'suggestion_type':suggestion_type
+        })
+
+    # Si SCP < seuil défini
+    if scp is not None and scp < DISENGAGED_THRESHOLD:
+        recommendations.append({
+            "titre": "Parcours populaires à ne pas manquer !",
+            "contenu": "Jette un œil au parcours suivant, qui a suscité beaucoup de réactions. Il pourrait t'offrir des perspectives nouvelles et inspirer tes prochaines explorations !",
+            'suggestion': suggestion,
+            'suggestion_type':suggestion_type
+        })
+
+    # Si SIS < seuil défini
+    if sis is not None and sis < DISENGAGED_THRESHOLD:
+        recommendations.append({
+            "titre": "Participe à la conversation !",
+            "contenu": "Nous avons remarqué que tu n'as pas interagi récemment. Pourquoi ne pas partager tes avis? Engage-toi avec l'annotation suivante, qui a déjà suscité beaucoup de réactions. Ton avis compte !",
+            'suggestion': suggestion,
+            'suggestion_type':suggestion_type
+        })
+
+    # Insérer les recommandations dans la base de données
+    for rec in recommendations:
+        insert_recommendation(user_id, "CAT2-Engagement", "CommunityReengage", rec["titre"], rec["contenu"], rec['suggestion'], rec['suggestion_type'])
+
+
+
+
+def generate_all_engagement_recommendations():
+    """
+    Génère des recommandations d'engagement pour tous les utilisateurs.
+    """
+    user_ids = fetch_user_ids()
+    
+    # Identifier des lieux intéressants non annotés
+    # non_annotated_places = identify_interesting_places()
+
+    # Récupérer les annotations populaires
+    popularity_data = fetch_annotation_popularity()
+    new_annotations = fetch_new_annotations()
+    
+    
+    for user in user_ids:
+        # Récupérer les indicateurs pour l'utilisateur
+        indicators = fetch_indicators(user)
+
+        
+        # Générer des recommandations pour chaque utilisateur
+        if indicators is not None and len(indicators) > 0:
+            suggester = Suggester(user_id=user)
+            generate_annotation_recommendations(user, indicators, suggester)
+            generate_location_based_recommendations(user, indicators, suggester)
+            generate_route_based_recommendations(user, indicators, suggester)
+            generate_reengagement_recommendations(user, indicators, suggester)
+            generate_history_based_reengagement_recommendations(user, indicators, suggester)
+            generate_community_content_recommendations(user, indicators, suggester)
+
+            # OLD VERSION
+            # generate_engagement_recommendations(user, indicators)
+            # suggest_opportune_annotation_locations(user)
+            # generate_contextual_tour_recommendations(user,user_location)
+            # generate_tour_creation_recommendations(user)
+            # generate_reactivation_recommendation(user)
+            # generate_recurrent_interest_recommendations(user)
+            # generate_popular_content_recommendations(user, popularity_data)
+            # generate_new_content_recommendations(user, new_annotations)
+        else:
+            print(f'No indicators found for user: {user}')
+
+
+
+
+
+
+
+
+
+
+
+
+
+###################  OLD VERSION
+def generate_engagement_recommendations(user_id, indicators):
+    """
+    Génère des recommandations pour améliorer l'exploration contextuelle des lieux annotés 
+    en fonction des indicateurs PLA et TEL pour un utilisateur donné.
+    """
+    pla =  get_indicator_value(indicators, 'PLA')
+    tel =  get_indicator_value(indicators, 'TEL') 
+
+    # Recommandations basées sur le PLA
+    if pla is not None and pla > THRESHOLD_PLA:
+        insert_recommendation(user_id, 
+                              "Engagement", 
+                              "Exploration Contextuelle des Lieux Annotés", 
+                              "Découvrez des lieux uniques proches de vous, annotés par d'autres utilisateurs. Explorez ces lieux et ajoutez vos propres annotations !")
+
+    # Recommandations basées sur le TEL
+    if tel is not None and tel < THRESHOLD_TEL:
+        insert_recommendation(user_id, 
+                              "Engagement", 
+                              "Exploration Contextuelle des Lieux Annotés", 
+                              "Nous avons remarqué que vous n'avez pas encore exploré de nombreux lieux annotés par d'autres. Consultez ces lieux recommandés pour enrichir votre expérience !")
+
+
+def identify_interesting_places():
+    """
+    Identifie des lieux intéressants non annotés en utilisant les données comportementales des utilisateurs.
+    """
+    # Récupérer les coordonnées des annotations existantes
+    annotations = fetch_annotations(include_fields=['coords'], field_filters_to_exclude={'user_id': []})
+    annotated_coords = set()
+    if not annotations.empty:
+        for _, annotation in annotations.iterrows():
+            coords = annotation['coords']
+            coords_dict = json.loads(coords)
+            annotated_coords.add((coords_dict['lat'], coords_dict['lon']))
+
+    # Récupérer les logs des utilisateurs pour identifier les lieux fréquemment visités
+    user_logs = fetch_user_logs()  # Ajustez cette fonction pour récupérer les logs des utilisateurs
+    place_visits = pd.DataFrame(user_logs)
+
+    # Identifier les lieux non annotés
+    non_annotated_places = place_visits[~place_visits.apply(lambda row: (row['lat'], row['lon']) in annotated_coords, axis=1)]
+    
+    return non_annotated_places
+
+def suggest_opportune_annotation_locations(user_id, start_date=None, days=None):
+    """
+    Suggère des lieux opportuns pour l'annotation contextuelle en fonction des lieux intéressants non encore annotés et des indicateurs OA et TCA.
+    """
+    indicators = fetch_indicators(user_id)
+    non_annotated_places =  get_indicator_value(indicators, "non_annotated_places")
+    oa =  get_indicator_value(indicators, "OA")
+    tca =  get_indicator_value(indicators, "TCA") 
+
+    if oa is None:
+        print(f"Indicateurs OA manquants pour l'utilisateur {user_id}. Recommandation non générée.")
+        return
+
+    # if oa > THRESHOLD_OA and tca < THRESHOLD_TCA:
+    # print('oa: ',oa)
+    # print('THRESHOLD_OA: ',THRESHOLD_OA)
+    if oa > THRESHOLD_OA:
+        for place in non_annotated_places:
+            lat, lon = place['lat'], place['lon']
+            recommendation = (f"Partagez votre découverte en annotant cet endroit spécial "
+                              f"({lat}, {lon}) que vous avez récemment visité ! "
+                              "Contribuez à enrichir notre communauté avec vos annotations uniques.")
+            insert_recommendation(user_id, "Engagement", 
+                                  "Annotation Contextuelle Opportune", recommendation)
+    else:
+        print(f"Pas de recommandations générées pour l'utilisateur {user_id}. OA={oa}, TCA={tca}")
+
+
+def generate_contextual_tour_recommendations(user_id, user_location, pp_threshold=5, tep_threshold=0.1):
+    """
+    Génère des recommandations de parcours contextuelles pour un utilisateur donné en fonction des indicateurs calculés.
+    
+    :param user_id: ID de l'utilisateur
+    :param user_location: Dictionnaire contenant 'lat' et 'lon' pour la localisation de l'utilisateur.
+    :param pp_threshold: Seuil pour la Proximité des Parcours (PP) pour déclencher une recommandation.
+    :param tep_threshold: Seuil pour le Taux d'Exploration de Parcours (TEP) pour déclencher une recommandation.
+    :return: None
+    """
+    # Récupération des indicateurs depuis la base de données
+    indicators = fetch_indicators(user_id)
+
+    
+    pp =  get_indicator_value(indicators, "PP")
+    tep =  get_indicator_value(indicators, "TEP")
+    
+    # Vérification que les indicateurs sont présents et valides
+    # if pp is None or tep is None:
+    if pp is None:
+        print(f"Indicateurs manquants pour l'utilisateur {user_id}. Recommandation non générée.")
+        return
+    
+    # Vérification des conditions pour déclencher la recommandation
+    # if pp < pp_threshold and tep >= tep_threshold:
+    if pp < pp_threshold :
+        # Sélection des parcours proches pour la recommandation
+        tours = fetch_tours()
+        recommended_tours = []
+
+        for _, tour in tours.iterrows():
+            coordinates = json.loads(tour['body'])
+            tour_coordinates = [(coordinates[i], coordinates[i+1]) for i in range(0, len(coordinates), 2)]
+            dist = calculate_min_distance_to_tour(user_location, tour_coordinates)
+
+            
+            if dist is not None and dist < pp_threshold:
+                recommended_tours.append(tour['id'])  # Suppose que chaque tour a un identifiant unique
+        
+        # Création et stockage de la recommandation dans la base de données
+        if recommended_tours:
+            recommendation_text = f"Découvrez ces parcours à proximité : {', '.join(map(str, recommended_tours))}. Explorez et partagez votre expérience !"
+            insert_recommendation(user_id, "Engagement", recommendation_text, recommended_tours)
+            print(f"Recommandation générée pour l'utilisateur {user_id}.")
+        else:
+            print(f"Aucun parcours proche trouvé pour l'utilisateur {user_id}.")
+    else:
+        print(f"Conditions non remplies pour générer une recommandation pour l'utilisateur {user_id}.")
+
+def generate_tour_creation_recommendations(user_id):
+    """
+    Génère des recommandations pour la création de parcours basées sur les opportunités identifiées et le taux de création de parcours.
+
+    :param user_id: ID de l'utilisateur
+    :return: Recommandations à envoyer à l'utilisateur
+    """
+    # Calcul des indicateurs pour l'utilisateur
+    indicators = fetch_indicators(user_id)
+
+    # Récupérer les indicateurs de la base de données
+    ocp =  get_indicator_value(indicators, "OCP")
+    tcp =  get_indicator_value(indicators, "TCP")
+    
+    if ocp is None:
+        print(f"Indicateurs OCP manquants pour l'utilisateur {user_id}. Recommandation non générée.")
+        return
+
+    # Définir les seuils
+    ocp_threshold = 10  # Ajuster selon les besoins
+    tcp_threshold = 0.1  # Ajuster selon les besoins
+
+    # Générer des recommandations si les seuils sont dépassés
+    # if ocp > ocp_threshold and tcp < tcp_threshold:
+    if ocp > ocp_threshold:
+        recommendation = (
+            "Créez un nouveau parcours dans cet endroit encore inexploré mais potentiellement très attractif pour la communauté ! "
+            "Partagez votre itinéraire unique et inspirez les autres utilisateurs avec votre découverte."
+        )
+        insert_recommendation(user_id, recommendation)
+        return recommendation
+    else:
+        return "Aucune recommandation à générer pour le moment."
+
+
+def generate_reactivation_recommendation(user_id, id_threshold=10, tru_threshold=0.1):
+    """
+    Crée une recommandation personnalisée pour réengager un utilisateur désengagé et l'enregistre.
+    
+    :param user_id: ID de l'utilisateur
+    :param id_threshold: Seuil de désengagement pour activer la recommandation
+    :param tru_threshold: Seuil de performance pour le taux de réactivation
+    :return: Message de recommandation
+    """
+    indicators = fetch_indicators(user_id)
+    ID =  get_indicator_value(indicators, "ID")
+    TRU =  get_indicator_value(indicators, "TRU")
+
+    if ID is None:
+        print(f"Indicateurs manquants pour l'utilisateur {user_id}. Recommandation non générée.")
+        return
+    
+    # Déterminer si la stratégie doit être activée
+    # if ID < id_threshold and TRU > tru_threshold:
+    if ID < id_threshold:
+        recommendation_message = "Nous avons remarqué que vous n'avez pas ajouté de nouvelles annotations récemment. Revenez explorer de nouveaux lieux et ajouter des annotations !  Découvrez ce qui a changé depuis votre dernière visite. "
+        category = "Engagement"
+        strategy = "Réactivation"
+        insert_recommendation(user_id, category, strategy, recommendation_message)
+    
+    return recommendation_message
+
+
+def generate_recurrent_interest_recommendations(user_id):
+    """
+    Génère des recommandations ciblées basées sur les intérêts récurrents de l'utilisateur 
+    seulement si l'indicateur AIR dépasse un seuil prédéfini.
+    
+    :param user_id: ID de l'utilisateur.
+    :param air: Valeur de l'indicateur AIR.
+    :return: Liste des recommandations générées.
+    """
+    indicators = fetch_indicators(user_id)
+    air =  get_indicator_value(indicators, "AIR")
+    recommendations = []
+
+    if air is None:
+        print(f"Indicateurs manquants pour l'utilisateur {user_id}. Recommandation non générée.")
+        return
+    
+    if air <= AIR_THRESHOLD:
+        return recommendations  # Ne pas générer de recommandations si AIR ne dépasse pas le seuil
+    
+    # Récupérer les annotations et parcours de l'utilisateur
+    annotations = fetch_user_annotations(user_id)
+    tours = fetch_user_tours(user_id)
+    
+    # Compter les occurrences de chaque type d'annotation
+    annotation_counts = defaultdict(lambda: {'frequency': 0, 'place_type': defaultdict(int)})
+    for _, row in annotations.iterrows():
+        timing = row.get('timing', 'unknown')
+        place_type = row.get('placeType', 'unknown')
+        annotation_counts[timing]['frequency'] += 1
+        annotation_counts[timing]['place_type'][place_type] += 1
+    
+    # Compter les occurrences de chaque type de parcours
+    tour_counts = defaultdict(lambda: {'count': 0, 'length': defaultdict(int)})
+    for _, row in tours.iterrows():
+        tour_type = row.get('type', 'unknown')
+        tour_length = len(json.loads(row.get('body', '[]')))  # Nombre de points dans le parcours
+        tour_counts[tour_type]['count'] += 1
+        tour_counts[tour_type]['length'][tour_length] += 1
+    
+    # Générer des recommandations basées sur les intérêts récurrents
+    for timing, details in annotation_counts.items():
+        if details['frequency'] > 5:  # Seuil arbitraire pour l'exemple
+            for place_type, count in details['place_type'].items():
+                if count > 3:  # Seuil arbitraire pour l'exemple
+                    recommendation = (
+                        f"Découvrez plus d'annotations {timing} dans la catégorie '{place_type}'. "
+                        f"Explorez des lieux intéressants similaires à ceux que vous avez souvent consultés."
+                    )
+                    recommendations.append(recommendation)
+                    insert_recommendation(user_id, "Engagement","Intérêts Récurrents", recommendation)
+    
+    for tour_type, details in tour_counts.items():
+        if details['count'] > 3:  # Seuil arbitraire pour l'exemple
+            for length, count in details['length'].items():
+                if count > 2:  # Seuil arbitraire pour l'exemple
+                    recommendation = (
+                        f"Explorez davantage de parcours de type '{tour_type}' avec {length} points. "
+                        f"Découvrez de nouveaux tracés similaires à ceux que vous avez souvent consultés."
+                    )
+                    recommendations.append(recommendation)
+                    insert_recommendation(user_id, "Engagement", "Intérêts Récurrents", recommendation)
+    
+    return recommendations
+
+def generate_filter_based_recommendations(user_id, threshold=5):
+    """
+    Génère des recommandations basées sur les filtres préférés de l'utilisateur.
+    
+    :param user_id: ID de l'utilisateur.
+    :param threshold: Seuil pour activer la génération des recommandations.
+    :return: Liste de recommandations sous forme de dictionnaires.
+    """
+    # Calculer PF et récupérer les motifs
+    indicators = fetch_indicators(user_id)
+    pf_value =  get_indicator_value(indicators, "PF")
+    motifs =  get_indicator_value(indicators, "PF_motifs")
+
+
+    # Ne pas générer de recommandations si PF est inférieur au seuil
+    if pf_value < threshold:
+        return []
+
+    # Générer des recommandations basées sur les motifs identifiés
+    recommendations = []
+
+    if motifs['institutions']:
+        for institution in motifs['institutions']:
+            recommendations.append({
+                "type": "institution",
+                "value": institution,
+                "message": f"Découvrez plus d'annotations liées à l'institution '{institution}'."
+            })
+
+    if motifs['nationalities']:
+        for nationality in motifs['nationalities']:
+            recommendations.append({
+                "type": "nationality",
+                "value": nationality,
+                "message": f"Explorez des annotations et parcours en relation avec la nationalité '{nationality}'."
+            })
+
+    if motifs['icons']:
+        for icon in motifs['icons']:
+            recommendations.append({
+                "type": "icon",
+                "value": icon,
+                "message": f"Découvrez plus de contenus associés à l'icône '{icon}'."
+            })
+
+    if motifs['emoticons']:
+        for emoticon in motifs['emoticons']:
+            recommendations.append({
+                "type": "emoticon",
+                "value": emoticon,
+                "message": f"Explorez des annotations qui expriment '{emoticon}'."
+            })
+
+    if motifs['tags']:
+        for tag in motifs['tags']:
+            recommendations.append({
+                "type": "tag",
+                "value": tag,
+                "message": f"Découvrez des annotations taguées avec '{tag}'."
+            })
+
+    if motifs['date_ranges']:
+        for date_range in motifs['date_ranges']:
+            recommendations.append({
+                "type": "date_range",
+                "value": date_range,
+                "message": f"Explorez des annotations créées entre {date_range}."
+            })
+
+    # Enregistrer les recommandations
+    for reco in recommendations:
+        insert_recommendation(user_id, "Engagement", reco['type'], reco['message'])
+
+    return recommendations
+
+def generate_popular_content_recommendations(user_id, popularity_data):
+    """
+    Génère des recommandations d'annotations populaires basées sur les affinités utilisateur enregistrées.
+
+    :param user_id: ID de l'utilisateur.
+    :param popularity_data: Liste des annotations populaires avec leurs popularités.
+    :return: Texte de recommandation pour les annotations populaires.
+    """
+    # Récupérer les indicateurs d'affinité
+    indicators = fetch_indicators(user_id)
+    aua = get_indicator_value(indicators, "AUA")
+
+    aua_affinities = get_indicator_value(indicators, "AUA_affinities")
+
+    
+    # Vérifier le seuil d'affinité utilisateur
+    if aua is None or aua_affinities is None or aua <= AUA_THRESHOLD_VALUE: 
+        print("Affinité utilisateur insuffisante pour les recommandations de contenus populaires.")
+        return "Nous n'avons pas trouvé de contenus populaires correspondant à vos préférences pour le moment."
+
+    # Assurer que aua_affinities est une liste d'IDs, sinon la convertir
+    if isinstance(aua_affinities, str):
+        aua_affinities = eval(aua_affinities)  # Convertir la chaîne en liste
+
+    # Récupérer les annotations vues par l'utilisateur
+    viewed_annotations = fetch_user_viewed_annotations(user_id)
+
+    # Filtrer et trier les annotations populaires non vues par l'utilisateur, par affinité décroissante
+    filtered_annotations = [
+        (annotation['annotation_id'], annotation['view_percentage'])
+        for annotation in popularity_data
+        if annotation['annotation_id'] in aua_affinities and annotation['annotation_id'] not in viewed_annotations  # Exclure les annotations déjà vues
+    ]
+    
+    # Trier par popularité (ou d'autres critères de pertinence si applicable)
+    filtered_annotations.sort(key=lambda x: x[1], reverse=True)
+    
+    # Sélectionner les annotations les plus pertinentes
+    top_annotations = filtered_annotations[:2]  # Sélectionner les deux annotations les plus pertinentes
+    
+    if not top_annotations:
+        return "Nous n'avons pas trouvé de contenus populaires correspondant à vos préférences pour le moment."
+
+    # Construire un texte de recommandation
+    recommended_text = "Découvrez des annotations populaires qui pourraient vous intéresser :"
+    for annotation_id, _ in top_annotations:
+        annotation_details = fetch_annotations_by_id(annotation_id).iloc[0]  # Assumer qu'il y a une ligne pour chaque ID
+        summary = annotation_details.get('summary', 'Annotation intéressante')
+        comment = annotation_details.get('comment', '...')[:100]
+        recommended_text += f"\n- {summary} : {comment}..."
+
+    # Enregistrer les recommandations
+    insert_recommendation(user_id, "Engagement", "Annotations Populaires", [id for id, _ in top_annotations])
+
+    return recommended_text
+
+
+def generate_new_content_recommendations(user_id, new_annotations):
+    """
+    Génère des recommandations d'annotations nouvelles basées sur les affinités utilisateur enregistrées.
+
+    :param user_id: ID de l'utilisateur.
+    :param new_annotations: Liste des annotations nouvelles avec leurs détails.
+    :return: Texte de recommandation pour les annotations nouvelles.
+    """
+    # Récupérer les indicateurs d'affinité
+    indicators = fetch_indicators(user_id)
+    aun = get_indicator_value(indicators, "AUN")
+    aun_affinities = get_indicator_value(indicators, "AUN_affinities")
+
+    # Vérifier le seuil d'affinité utilisateur pour les nouveautés
+    if aun is None or aun_affinities is None or aun <= AUN_THRESHOLD_VALUE:  # Remplacer AUN_THRESHOLD_VALUE par une valeur appropriée
+        print("Affinité utilisateur insuffisante pour les recommandations de nouveautés.")
+        return "Nous n'avons pas trouvé de nouveautés correspondant à vos préférences pour le moment."
+
+    # Assurer que aun_affinities est une liste d'IDs, sinon la convertir
+    if isinstance(aun_affinities, str):
+        aun_affinities = eval(aun_affinities)  # Convertir la chaîne en liste
+
+    # Récupérer les annotations vues par l'utilisateur
+    viewed_annotations = fetch_user_viewed_annotations(user_id)
+
+    # Filtrer et trier les nouvelles annotations non vues par l'utilisateur, par date de création décroissante
+    filtered_annotations = [
+        (annotation['post_id'], annotation['created_at'])
+        for _, annotation in new_annotations.iterrows()
+        if annotation['post_id'] in aun_affinities and annotation['post_id'] not in viewed_annotations  # Exclure les annotations déjà vues
+    ]
+    
+    # Trier par date de création (ou d'autres critères de pertinence si applicable)
+    filtered_annotations.sort(key=lambda x: x[1], reverse=True)
+
+    # Sélectionner les annotations les plus pertinentes
+    top_annotations = filtered_annotations[:2]  # Sélectionner les deux annotations les plus pertinentes
+    
+    if not top_annotations:
+        return "Nous n'avons pas trouvé de nouveautés correspondant à vos préférences pour le moment."
+
+    # Construire un texte de recommandation
+    recommended_text = "Découvrez les dernières annotations qui pourraient vous intéresser :"
+    for annotation_id, _ in top_annotations:
+        annotation_details = fetch_annotations_by_id(annotation_id).iloc[0]  # Assumer qu'il y a une ligne pour chaque ID
+        summary = annotation_details.get('summary', 'Nouvelle annotation')
+        comment = annotation_details.get('comment', '...')[:100]
+        recommended_text += f"\n- {summary} : {comment}..."
+
+    # Enregistrer les recommandations
+    insert_recommendation(user_id, "Engagement", "Annotations Nouvelles", [id for id, _ in top_annotations])
+
+    return recommended_text
+
+
diff --git a/rs/modules/recommendations/interaction_reflection_rec.py b/rs/modules/recommendations/interaction_reflection_rec.py
new file mode 100644
index 0000000000000000000000000000000000000000..856efc127b75ea2b26f9b465aee2e17984147acb
--- /dev/null
+++ b/rs/modules/recommendations/interaction_reflection_rec.py
@@ -0,0 +1,156 @@
+from modules.db_operations import fetch_indicators, get_indicator_value, insert_recommendation
+from modules.es_operations import fetch_user_ids
+
+TAC_THRESHOLD = 0.1
+TEC_THRESHOLD = 0.2
+TCC_THRESHOLD = 0.8
+DSA_THRESHOLD = 0.7
+TRC_THRESHOLD = 0.5
+TCCA_THRESHOLD = 0.1
+
+def generate_auto_evaluation_recommendation(user_id):
+    """
+    Génère une recommandation pour encourager les utilisateurs à améliorer leurs contributions en révisant
+    et en modifiant leurs annotations.
+    
+    :param user_id: ID de l'utilisateur.
+    """
+    # Récupérer les indicateurs d'auto-évaluation
+    indicators = fetch_indicators(user_id)
+    tac = get_indicator_value(indicators, "TAC")
+    
+    
+    # Vérifier les conditions de déclenchement
+    if tac < TAC_THRESHOLD:
+        # Générer la recommandation
+        recommendation_message = (
+            "Revoyez vos annotations et apportez des améliorations pour enrichir vos contributions. "
+            "Explorez les commentaires pour voir comment vous pouvez ajuster et améliorer !"
+        )
+        
+        # Enregistrer la recommandation
+        insert_recommendation(
+            user_id,
+            "Interaction et réflexion",
+            "Auto-Évaluation et Amélioration des Contributions",
+            recommendation_message
+        )
+        
+        print(f"Recommandation générée pour l'utilisateur {user_id}.")
+    else:
+        print(f"Taux d'Amélioration des Contributions suffisant pour l'utilisateur {user_id}. Pas de recommandation nécessaire.")
+
+
+def generate_interaction_community_recommendation(user_id):
+    """
+    Génère une recommandation pour encourager les utilisateurs à commenter et répondre aux contributions des autres.
+    
+    :param user_id: ID de l'utilisateur.
+    """
+    # Récupérer les indicateurs d'engagement communautaire
+    indicators = fetch_indicators(user_id)
+    tec = get_indicator_value(indicators, "TEC")
+    
+    
+    # Vérifier les conditions de déclenchement
+    if tec < TEC_THRESHOLD:
+        # Générer la recommandation
+        recommendation_message = (
+            "Participez aux discussions : commentez et répondez aux contributions des autres membres pour "
+            "enrichir les échanges et améliorer l'engagement communautaire !"
+        )
+        
+        # Enregistrer la recommandation
+        insert_recommendation(
+            user_id,
+            "Interaction et réflexion",
+            "Interaction avec les Contributions Communautaires",
+            recommendation_message
+        )
+        
+        print(f"Recommandation générée pour l'utilisateur {user_id}.")
+    else:
+        print(f"Taux d'Engagement Communautaire suffisant pour l'utilisateur {user_id}. Pas de recommandation nécessaire.")
+
+def generate_reactivity_to_comments_recommendation(user_id):
+    """
+    Génère une recommandation pour encourager les utilisateurs à répondre aux commentaires reçus sur leurs annotations.
+    
+    :param user_id: ID de l'utilisateur.
+    """
+    # Récupérer les indicateurs de réactivité aux commentaires
+    indicators = fetch_indicators(user_id)
+    trc = get_indicator_value(indicators, "TRC")
+    
+    
+    # Vérifier les conditions de déclenchement
+    if trc < TRC_THRESHOLD:
+        # Générer la recommandation
+        recommendation_message = (
+            "Répondez aux commentaires sur vos annotations pour enrichir le dialogue et améliorer la collaboration !"
+        )
+        
+        # Enregistrer la recommandation
+        insert_recommendation(
+            user_id,
+            "Interaction et réflexion",
+            "Réactivité aux Commentaires sur les Contributions",
+            recommendation_message
+        )
+        
+        print(f"Recommandation générée pour l'utilisateur {user_id}.")
+    else:
+        print(f"Taux de Réponse aux Commentaires suffisant pour l'utilisateur {user_id}. Pas de recommandation nécessaire.")
+
+
+def generate_commenting_others_recommendation(user_id):
+    """
+    Génère une recommandation pour encourager les utilisateurs à commenter les contributions des autres membres.
+    
+    :param user_id: ID de l'utilisateur.
+    """
+    # Récupérer les indicateurs de commentaires sur les contributions des autres
+    indicators = fetch_indicators(user_id)
+    tcca = get_indicator_value(indicators, "TCC")
+    
+    # Définir le seuil pour déclencher la recommandation
+    
+    
+    # Vérifier les conditions de déclenchement
+    if tcca < TCCA_THRESHOLD:
+        # Générer la recommandation
+        recommendation_message = (
+            "Participez activement en commentant les contributions des autres membres pour enrichir les échanges "
+            "et renforcer la communauté !"
+        )
+        
+        # Enregistrer la recommandation
+        insert_recommendation(
+            user_id,
+            "Interaction et réflexion",
+            "Commenter les Contributions des Autres",
+            recommendation_message
+        )
+        
+        print(f"Recommandation générée pour l'utilisateur {user_id}.")
+    else:
+        print(f"Taux de Commentaire sur les Contributions des Autres suffisant pour l'utilisateur {user_id}. Pas de recommandation nécessaire.")
+
+
+def generate_all_reflection_recommendations():
+    """
+    Génère des recommandations d'engagement pour tous les utilisateurs.
+    """
+    user_ids = fetch_user_ids()
+    for user in user_ids:
+        # Récupérer les indicateurs pour l'utilisateur
+        indicators = fetch_indicators(user)
+        # Générer des recommandations pour chaque utilisateur
+        if indicators is not None and len(indicators) > 0:
+            generate_auto_evaluation_recommendation(user)
+            generate_interaction_community_recommendation(user)
+            generate_reactivity_to_comments_recommendation(user)
+            generate_commenting_others_recommendation(user)
+            
+        else:
+            print(f'No indicators found for user: {user}')
diff --git a/rs/modules/recommendations/urban_discovery_rec.py b/rs/modules/recommendations/urban_discovery_rec.py
new file mode 100644
index 0000000000000000000000000000000000000000..4d1ca3ca0a208b4555a58e60b2e95fc4ba6c0139
--- /dev/null
+++ b/rs/modules/recommendations/urban_discovery_rec.py
@@ -0,0 +1,254 @@
+from modules.db_operations import fetch_annotations_around_location, fetch_indicators, fetch_user_annotations, get_indicator_value, insert_recommendation
+from modules.es_operations import fetch_user_ids, fetch_visited_locations
+from modules.metrics.urban_discovery_m import calculate_harv_distance, get_pois_nearby, get_unexplored_zones
+from modules.suggester import Suggester
+
+
+DAP_THRESHOLD = 3 # Ce seuil signifie que s'il y a moins de 3 annotations en moyenne par POI, les lieux visités par l'utilisateur sont considérés comme peu pertinents pour des recommandations. En dessous de ce seuil, l'utilisateur a visité des POI avec peu de documentation, ce qui pourrait suggérer un manque d'intérêt général.
+
+
+PRL_THRESHOLD = 0.8  # Si le PRL est inférieur à 0.8, cela signifie que les POI visités sont moins populaires que la moyenne. Cela peut indiquer que l'utilisateur visite des lieux qui ne sont pas couramment visités par d'autres, donc peut-être moins pertinents pour des recommandations basées sur les tendances.
+
+# Justification : Le seuil DMAPP (Distance Mean Average Proximity Points) est fixé à 5000 mètres (5 km).
+DMAPP_THRESHOLD = 5000  # en mètres
+
+SPC_THRESHOLD = 2.0  # Surface en km² (ajustez selon les données)
+
+
+DAU_THRESHOLD = 3  # nombre d'annotations (entier sans unité)
+
+
+DC_THRESHOLD = 0.8  # proportion (sans unité)
+
+
+DEFAULT_SINCE_DATE = "2024-01-01"
+
+def get_selected_annotation_id(poi):
+    """
+    Retourne l'ID de l'annotation sélectionnée pour un POI donné.
+    
+    :param poi: Dictionnaire représentant un Point d'Intérêt (POI).
+    :return: ID de l'annotation sélectionnée.
+    """
+    # Supposons que l'ID de l'annotation est inclus dans le POI comme une liste ou un dictionnaire
+    if 'annotations' in poi and poi['annotations']:
+        return poi['annotations'][0]['id']  # Retourne l'ID de la première annotation
+    return None
+
+
+def generate_poi_recommendations(user_id, suggester):
+    """
+    Génère une recommandation de POI basée sur les tendances de visite de l'utilisateur et l'enregistre.
+    
+    :param user_id: ID de l'utilisateur.
+    :return: Texte de recommandation pour les POI.
+    """
+    # Récupérer les indicateurs d'affinité
+    indicators = fetch_indicators(user_id)
+    dap = get_indicator_value(indicators, "DAP")
+    prl = get_indicator_value(indicators, "PRL")
+    
+    if dap is None or prl is None:
+        print(f"DAP ou PRL non trouvé pour l'utilisateur {user_id}.")
+        return "Nous n'avons pas trouvé de POI correspondant à vos préférences pour le moment."
+    
+    # Calculer le score combiné basé sur les deux indicateurs
+    combined_score = (dap / DAP_THRESHOLD) * 0.5 + (prl / PRL_THRESHOLD) * 0.5
+    
+    # Si le score combiné est inférieur à 1, ne pas recommander
+    if combined_score < 1:
+        print("Affinité utilisateur insuffisante pour les recommandations de POI.")
+        return "Nous n'avons pas trouvé de POI correspondant à vos préférences pour le moment."
+    
+    # Récupérer les lieux visités et les POI à proximité
+    visited_locations = fetch_visited_locations(user_id)
+    nearby_pois = get_pois_nearby(visited_locations)
+    
+    if not nearby_pois:
+        return "Aucun POI pertinent trouvé autour des lieux visités."
+
+    # Trier les POI par nombre d'annotations décroissant
+    top_pois = sorted(nearby_pois, key=lambda x: x['annotations_count'], reverse=True)[:5]  # Sélectionner les 5 POI les plus pertinents
+    
+    # Sélectionner une annotation à partir du meilleur POI
+    selected_annotation_id = None
+    if top_pois:
+        selected_annotation_id = top_pois[0]['annotation_ids'][0]  # Prendre le premier ID d'annotation
+
+    
+
+    # Enregistrer la recommandation
+    insert_recommendation(
+        user_id=user_id,
+        category="CAT4-Urban",
+        strategy="POIDiscover",
+        title="Découvre les lieux incontournables autour de toi !",
+        recommendation="Plonge dans l’exploration des endroits les plus prisés proches de ceux que tu as déjà annotés près de chez toi ! Ces lieux pourraient vraiment enrichir ton expérience. Pour commencer, regarde cette annotation populaire qui pourrait t'inspirer ",
+        suggestion=selected_annotation_id,
+        suggestion_type="Annotation"
+    )
+
+
+
+
+# Stratégie 2 : Exploration Équilibrée des Zones Urbaines
+def suggeste_uexplored_zone_annotation(user_id):
+        """
+        Récupère l'ID d'une annotation qui incite l'utilisateur à élargir sa couverture géographique.
+
+        :param user_id: ID de l'utilisateur.
+        :return: ID d'une annotation pertinente ou None.
+        """
+        # Récupérer les annotations existantes de l'utilisateur
+        annotations = fetch_user_annotations(user_id)  # Implémentez cette fonction pour récupérer les annotations
+
+        # Identifier les zones inexplorées
+        unexplored_zones = get_unexplored_zones(user_id, annotations)
+
+        if not unexplored_zones:
+            print(f"Aucune zone inexplorée trouvée pour l'utilisateur {user_id}.")
+            return None
+
+        # Pour chaque zone inexplorée, récupérer les annotations autour
+        for zone in unexplored_zones:
+            location = {'lat': zone[0], 'lon': zone[1]}
+            nearby_annotations = fetch_annotations_around_location(location, radius_km=5)
+            
+            if nearby_annotations:
+                # Retourner l'ID de la première annotation trouvée à proximité
+                return nearby_annotations[0][0]  # ID de l'annotation
+
+        return None
+
+
+
+def generate_exploration_recommendations(user_id, suggester):
+    """
+    Génère une recommandation pour une exploration plus équilibrée des zones urbaines et l'enregistre.
+    
+    :param user_id: ID de l'utilisateur.
+    :param suggester: Paramètre supplémentaire pour fournir des informations sur le suggester.
+    :return: Texte de recommandation pour l'exploration des zones urbaines.
+    """
+    indicators = fetch_indicators(user_id)
+    dmapp = get_indicator_value(indicators, "DMAPP")
+    spc = get_indicator_value(indicators, "SPC")
+
+    # Vérification si les indicateurs sont disponibles
+    if dmapp is None or spc is None:
+        print(f"DMAPP ou SPC non trouvé pour l'utilisateur {user_id}.")
+        return "Nous n'avons pas trouvé de recommandations d'exploration pour vous pour le moment."
+
+    # Vérifier les conditions de déclenchement
+    if dmapp < DMAPP_THRESHOLD and spc < SPC_THRESHOLD:
+        recommended_text = (
+            "Nous avons remarqué que vos explorations urbaines sont concentrées dans certaines zones. "
+            "Essayez de visiter de nouvelles zones pour diversifier vos explorations urbaines."
+        )
+        
+        # Enregistrer la recommandation
+        insert_recommendation(
+            user_id=user_id,
+            category="CAT4-Urban",
+            strategy="CoverageExpand",
+            title="Pars découvrir de nouveaux quartiers !",
+            recommendation=(
+                "Tes annotations se situent toujours dans les mêmes lieux. "
+                "Élargis ton horizon en explorant cette annotation dans un quartier encore inexploré. "
+                "C’est l’occasion parfaite de partager tes découvertes et d’enrichir ta carte !"
+            ),
+            suggestion=suggeste_uexplored_zone_annotation(user_id),  # Utiliser le paramètre suggester
+            suggestion_type="Annotation"  # Assurez-vous que ce champ est approprié
+        )
+
+
+
+# Stratégie 3 : Diversification des Types d’Annotations Urbaines
+def generate_annotation_diversity_recommendations(user_id, suggester):
+    """
+    Génère une recommandation pour diversifier les types d'annotations urbaines de l'utilisateur et l'enregistre.
+    
+    :param user_id: ID de l'utilisateur.
+    :param since_date: Date à partir de laquelle les annotations sont considérées.
+    :return: Texte de recommandation pour la diversification des annotations urbaines.
+    """
+
+    
+    indicators = fetch_indicators(user_id)
+    dau = get_indicator_value(indicators, "DAU")
+
+    if dau is None:
+        print(f"DAU non trouvé pour l'utilisateur {user_id}.")
+        return "Nous n'avons pas trouvé de recommandations pour diversifier vos annotations urbaines pour le moment."
+
+    # Vérifier les conditions de déclenchement
+    if dau < DAU_THRESHOLD:
+        recommended_text = (
+            "Nous avons remarqué que vos annotations se concentrent sur certains types. "
+            "Essayez de diversifier vos annotations en explorant différents aspects urbains."
+        )
+        
+        # Enregistrer la recommandation
+        insert_recommendation(
+            user_id=user_id,
+            category="CAT4-Urban",
+            strategy="Diversification des Types d’Annotations Urbaines",
+            title="Ose explorer de nouveaux horizons !",
+            recommendation="Il est temps d'élargir tes découvertes ! En annotant des zones encore inexplorées, tu enrichiras ton expérience et découvriras des facettes inédites de la ville. Pour commencer, jette un œil à cette annotation qui pourrait t'inspirer.",
+            suggestion="suggestion",
+            suggestion_type="suggestion_type"
+        )
+
+
+
+# Stratégie 4 : Réduction de la Concentration des Annotations
+def generate_annotation_concentration_recommendations(user_id, suggester):
+    """
+    Génère une recommandation pour réduire la concentration des annotations urbaines de l'utilisateur et l'enregistre.
+    
+    :param user_id: ID de l'utilisateur.
+    :return: Texte de recommandation pour la réduction de la concentration des annotations.
+    """    
+    indicators = fetch_indicators(user_id)
+    dc = get_indicator_value(indicators, "DC")
+
+    if dc is None:
+        print(f"DC non trouvé pour l'utilisateur {user_id}.")
+        return "Nous n'avons pas trouvé de recommandations pour réduire la concentration de vos annotations pour le moment."
+
+    # Vérifier les conditions de déclenchement
+    if dc > DC_THRESHOLD:
+        recommended_text = (
+            "Nous avons remarqué que vos annotations sont très concentrées dans certaines zones. "
+            "Essayez de disperser vos annotations pour explorer davantage la ville."
+        )
+        
+        # Enregistrer la recommandation
+        insert_recommendation(
+            user_id=user_id,
+            category="CAT4-Urban",
+            strategy="ConcentrationReduce",
+            recommendation=recommended_text,
+            suggestion="suggestion",
+            suggestion_type="suggestion_type"
+        )
+
+
+def generate_all_urban_recommendations():
+    """
+    Génère des recommandations d'engagement pour tous les utilisateurs.
+    """
+    user_ids = fetch_user_ids()
+    for user in user_ids:
+        # Récupérer les indicateurs pour l'utilisateur
+        indicators = fetch_indicators(user)
+
+        suggester = Suggester(user_id=user)
+        # Générer des recommandations pour chaque utilisateur
+        if indicators is not None and len(indicators) > 0:
+            generate_poi_recommendations(user, suggester)
+            # generate_annotation_concentration_recommendations(user, suggester)
+            # generate_exploration_recommendations(user, suggester)
+            # generate_annotation_diversity_recommendations(user, suggester)
+        else:
+            print(f'No indicators found for user: {user}')
diff --git a/rs/modules/suggester.py b/rs/modules/suggester.py
new file mode 100644
index 0000000000000000000000000000000000000000..f7eb4dd6257a949e473602e6e4437741ae1f91b2
--- /dev/null
+++ b/rs/modules/suggester.py
@@ -0,0 +1,725 @@
+from collections import defaultdict
+import json
+import pandas as pd
+from geopy.distance import great_circle
+from sklearn.cluster import DBSCAN
+
+from modules.db_operations import fetch_annotation_comments, fetch_annotations, fetch_annotations_around_location, fetch_new_annotations, fetch_tours, fetch_user_annotations, fetch_user_profile, fetch_user_tours
+from modules.es_operations import fetch_annotation_popularity, fetch_interaction_count_for_content, fetch_user_filters
+from modules.metrics.urban_discovery_m import calculate_harv_distance
+from web_app.models import UserProfile
+
+import json
+import pandas as pd
+from geopy.distance import geodesic
+
+class Suggester:
+    
+    def __init__(self, user_id):
+        self.user_id = user_id
+        self.user_profile = self.fetch_user_profile(user_id)
+        self.user_annotations = self.fetch_user_annotations(user_id)
+        self.others_annotations = self.fetch_others_annotations(user_id)
+        self.user_tours = self.fetch_user_tours(user_id)
+        self.others_tours = self.fetch_others_tours(user_id)
+
+        self.user_filters = self.analyze_filters(user_id)
+
+        self.user_locations = self.process_user_locations()
+
+        self.unexplored_zones = self.get_unexplored_zones()
+
+    # Méthodes pour récupérer les données nécessaires
+    def fetch_user_profile(self, user_id):
+        """Récupérer le profil de l'utilisateur."""
+        return fetch_user_profile(user_id)
+
+    def fetch_user_annotations(self, user_id):
+        """Récupérer toutes les annotations de l'utilisateur."""
+        return fetch_user_annotations(user_id)
+    
+    def process_user_locations(self):
+        # Assurez-vous que la colonne 'coords' existe dans le DataFrame
+        if 'coords' in self.user_annotations.columns:
+            # Récupérer toutes les coordonnées valides (non nulles)
+            self.user_locations = self.user_annotations['coords'].dropna().tolist()
+        else:
+            print("La colonne 'coords' n'existe pas dans self.user_annotations.")
+            self.user_locations = []
+
+    
+    def fetch_others_annotations(self, user_id):
+        """Récupérer toutes les annotations des autres utilisateurs."""
+        return fetch_annotations(field_filters_to_exclude={'user': [user_id]})
+
+    def fetch_user_tours(self, user_id):
+        """Récupérer tous les parcours de l'utilisateur."""
+        return fetch_user_tours(user_id)
+    
+    def fetch_others_tours(self, user_id):
+        """Récupérer toutes les annotations des autres utilisateurs."""
+        return fetch_tours(field_filters_to_exclude={'user': [user_id]})
+
+
+    def analyze_filters(self, user_id, since_date=None):
+        """
+        Calcule les Préférences de Filtres (PF) pour un utilisateur donné en identifiant les motifs spécifiques récurrents.
+
+        Args:
+            user_id (int): L'ID de l'utilisateur.
+            since_date (str, optional): Date à partir de laquelle récupérer les filtres.
+
+        Returns:
+            dict: Dictionnaire contenant la PF et les motifs trouvés.
+        """
+        # Récupérer les filtres appliqués par l'utilisateur
+        filters = fetch_user_filters(user_id, since_date)
+
+        # Initialiser les compteurs pour chaque motif de filtre
+        institution_counts = defaultdict(int)
+        nationality_counts = defaultdict(int)
+        icon_counts = defaultdict(int)
+        emoticon_counts = defaultdict(int)
+        tag_counts = defaultdict(int)
+        date_range_counts = defaultdict(int)
+
+        # Parcourir chaque filtre appliqué
+        for _, row in filters.iterrows():
+            # Compter les institutions
+            institution = row.get('institution')
+            if isinstance(institution, list):
+                for inst in institution:
+                    institution_counts[inst] += 1
+
+            # Compter les nationalités
+            nationality = row.get('nationality')
+            if isinstance(nationality, list):
+                for nat in nationality:
+                    nationality_counts[nat] += 1
+
+            # Compter les icônes
+            icons = row.get('icons', [])
+            if isinstance(icons, list):
+                for icon in icons:
+                    icon_counts[icon] += 1
+
+            # Compter les émoticônes
+            emoticons = row.get('emoticon', [])
+            if isinstance(emoticons, list):      
+                for emoticon in emoticons:
+                    emoticon_counts[emoticon] += 1
+
+            # Compter les tags
+            tags = row.get('tags', [])
+            if isinstance(tags, list):           
+                for tag in tags:
+                    tag_counts[tag] += 1
+
+            # Compter les plages de dates
+            begin_date = row.get('beginDate')
+            end_date = row.get('endDate')
+            if begin_date and end_date:
+                date_range = f"{begin_date} - {end_date}"
+                date_range_counts[date_range] += 1
+
+        # Identifier les motifs les plus fréquents
+        top_institutions = [k for k, v in institution_counts.items() if v > 1]
+        top_nationalities = [k for k, v in nationality_counts.items() if v > 1]
+        top_icons = [k for k, v in icon_counts.items() if v > 1]
+        top_emoticons = [k for k, v in emoticon_counts.items() if v > 1]
+        top_tags = [k for k, v in tag_counts.items() if v > 1]
+        top_date_ranges = [k for k, v in date_range_counts.items() if v > 1]
+
+        # Consolider les motifs
+        motifs = {
+            'institutions': top_institutions,
+            'nationalities': top_nationalities,
+            'icons': top_icons,
+            'emoticons': top_emoticons,
+            'tags': top_tags,
+            'date_ranges': top_date_ranges
+        }
+
+        # Calculer la fréquence totale et la moyenne
+        total_filters = (
+            sum(institution_counts.values()) +
+            sum(nationality_counts.values()) +
+            sum(icon_counts.values()) +
+            sum(emoticon_counts.values()) +
+            sum(tag_counts.values()) +
+            sum(date_range_counts.values())
+        )
+
+        total_motifs = len(top_institutions) + len(top_nationalities) + len(top_icons) + len(top_emoticons) + len(top_tags) + len(top_date_ranges)
+
+        PF = total_filters / total_motifs if total_motifs > 0 else 0
+
+        # Convertir les motifs en chaîne JSON
+        motifs_json = json.dumps(motifs)
+        
+        return {
+            'PF': PF,
+            'motifs': motifs_json
+        }
+    
+
+    import json
+
+    def extract_interests_data(self, prefix):
+        """Extrait et analyse les intérêts de l'utilisateur en fonction du préfixe donné."""
+        profile = self.user_profile.iloc[0] if isinstance(self.user_profile, pd.DataFrame) else self.user_profile
+
+        
+        interests = {
+            'user_id': profile.get('user_id', 0),
+            'items_count': profile.get(f'{prefix}_items_count', 0),
+            'avg_volume_lexical': profile.get(f'{prefix}_avg_volume_lexical', 0.0),
+            'icon_usage': json.loads(profile.get(f'{prefix}_icon_type_ratio', '{}')),
+            'tag_usage': json.loads(profile.get(f'{prefix}_tag_type_ratio', '{}')),
+            'emoticon_usage': json.loads(profile.get(f'{prefix}_emoticon_top3', '[]')),
+            'image_presence_percentage': profile.get(f'{prefix}_image_presence_percentage', 0.0),
+            'place_types': json.loads(profile.get(f'{prefix}_place_type_usage', '{}')),
+            'experience_frequency': json.loads(profile.get(f'{prefix}_experience_frequency', '{}')),
+            'covered_zones': profile.get(f'{prefix}_covered_zones', '[]'),
+            # 'covered_zones': json.loads(profile.get(f'{prefix}_covered_zones', '[]')),
+        }
+        
+        # Si le préfixe est PCoA, ajouter des motifs récurrents
+        if prefix == 'pcoa':
+            interests['recurrent_motifs'] = profile.get(f'{prefix}_recurrent_motifs', '[]')
+            # interests['recurrent_motifs'] = json.loads(profile.get(f'{prefix}_recurrent_motifs', '[]'))
+
+        return interests
+
+    
+    def analyze_interests(self):
+        """Analyse les intérêts de l'utilisateur pour PCA et PCoA."""
+        pca_interests = self.extract_interests_data('pca')
+        pcoa_interests = self.extract_interests_data('pcoa')
+        
+        return {
+            'pca': pca_interests,
+            'pcoa': pcoa_interests
+        }
+
+    
+    def parse_json(self, json_string):
+        """Parser une chaîne JSON en un objet Python, retourner un dictionnaire vide en cas d'erreur."""
+        if isinstance(json_string, dict):
+            return json_string
+        elif isinstance(json_string, str):
+            try:
+                return json.loads(json_string) if json_string else {}
+            except json.JSONDecodeError:
+                return {}
+        return {}
+
+    def compute_annotation_score(self, annotation):
+        """
+        Calcule le score de qualité d'une annotation en fonction de plusieurs critères pondérés.
+
+        Paramètres:
+        - annotation (DataFrame): Une annotation individuelle sous forme de DataFrame.
+
+        Retourne:
+        - float: Le score de qualité de l'annotation.
+        """
+        score = 0
+        total_weight = 0
+
+        # Critères de qualité avec leurs poids respectifs
+        criteria_weights = {
+            'message_length': 0.3,  # Longueur du message
+            'graphical': 0.2,       # Utilisation d'images (1 ou 0)
+            'num_tags': 0.2,        # Nombre de tags
+            'num_icons': 0.2,       # Nombre d'icônes
+            'diversity_of_icons': 0.1  # Diversité des icônes (pourcentage ou ratio)
+        }
+
+        # Calculer le score pour chaque critère
+        for criterion, weight in criteria_weights.items():
+            if criterion == 'message_length':
+                score += min(len(annotation['messages']) / 100, 1) * weight  # Normalisation de la longueur
+            elif criterion == 'graphical':
+                score += annotation['graphical'] * weight
+            elif criterion == 'num_tags':
+                score += min(len(annotation['tags']), 5) / 5 * weight  # Normalisation sur 5 tags max
+            elif criterion == 'num_icons':
+                score += min(annotation['total_icons_count'], 10) / 10 * weight  # Normalisation sur 10 icônes max
+            elif criterion == 'diversity_of_icons':
+                score += annotation['diversity_types_icons_used'] * weight  # Supposé normalisé à 1
+
+            total_weight += weight
+
+        # Retourner le score normalisé
+        return score / total_weight if total_weight > 0 else 0
+
+
+    def calculate_distance(self, loc1, loc2):
+        """Calculer la distance entre deux coordonnées géographiques."""
+        return geodesic((loc1['lat'], loc1['lon']), (loc2['lat'], loc2['lon'])).kilometers
+
+    def correct_coordinates(self, lat, lon):
+        """
+        Corrige les coordonnées pour s'assurer qu'elles sont dans les plages valides.
+        
+        :param lat: Latitude à corriger
+        :param lon: Longitude à corriger
+        :return: Tuple contenant la latitude et la longitude corrigées
+        """
+        lat = max(min(lat, 90), -90)
+        lon = max(min(lon, 180), -180)
+        return lat, lon
+    def calculate_min_distance_to_tour(self, user_location, tour_coordinates):
+        """
+        Calcule la distance minimale entre la localisation de l'utilisateur et un parcours.
+
+        :param user_location: Dictionnaire contenant 'lat' et 'lon' pour la localisation de l'utilisateur.
+        :param tour_coordinates: Liste de tuples représentant les points du parcours [(lat, lon), (lat, lon), ...].
+        :return: Distance minimale en kilomètres.
+        """
+        min_distance = float('inf')
+        if not isinstance(user_location, dict) or 'lat' not in user_location or 'lon' not in user_location:
+            return None
+
+        for coord in tour_coordinates:
+            point = self.correct_coordinates(coord[1], coord[0])
+            distance = geodesic((user_location['lat'], user_location['lon']), point).kilometers
+            if distance < min_distance:
+                min_distance = distance
+
+        return min_distance
+
+    # Méthodes de suggestion
+    def suggest_rich_annotations(self):
+        """Suggérer des annotations riches (avec beaucoup de détails)."""
+        rich_annotations = []
+        for _, annotation in self.others_annotations.iterrows():
+            if self.compute_annotation_score(annotation) >= .5:
+                rich_annotations.append(annotation)
+        return rich_annotations
+
+    def suggest_popular_annotations(self):
+        """
+        Suggérer les 5 annotations les plus populaires en fonction du nombre d'interactions.
+
+        :return: Liste des 5 annotations les plus populaires
+        """
+        annotations_with_interactions = []
+
+        # Parcourir les annotations
+        for _, annotation in self.others_annotations.iterrows():
+            content_id = annotation.get('id')
+
+            # Récupérer le nombre d'interactions pour chaque annotation
+            interaction_count = fetch_interaction_count_for_content(content_id=content_id)
+
+            # Ajouter l'annotation et le nombre d'interactions à une liste
+            annotations_with_interactions.append({
+                'annotation': annotation,
+                'interaction_count': interaction_count
+            })
+
+        # Trier les annotations par nombre d'interactions décroissant
+        annotations_with_interactions.sort(key=lambda x: x['interaction_count'], reverse=True)
+
+        # Renvoyer les 5 annotations avec le plus d'interactions
+        return [item['annotation'] for item in annotations_with_interactions[:5]]
+
+    def suggest_active_annotations(self):
+        """
+        Suggérer les 5 annotations ayant reçu le plus grand nombre de commentaires.
+
+        :return: Liste des 5 annotations les plus actives.
+        """
+        annotations_with_comments = []
+
+        # Parcourir les annotations
+        for _, annotation in self.others_annotations.iterrows():
+            annotation_id = annotation.get('content_id')
+
+            # Récupérer les commentaires pour chaque annotation
+            comments = fetch_annotation_comments(annotation_id)
+
+            # Ajouter l'annotation et le nombre de commentaires à une liste
+            annotations_with_comments.append({
+                'annotation': annotation,
+                'comment_count': len(comments)
+            })
+
+        # Trier les annotations par nombre de commentaires décroissant
+        annotations_with_comments.sort(key=lambda x: x['comment_count'], reverse=True)
+
+        # Renvoyer les 5 annotations avec le plus de commentaires
+        return [item['annotation'] for item in annotations_with_comments[:5]]
+
+
+    def suggest_new_annotations(self, days_threshold=7):
+        """Suggérer des annotations récentes."""
+        new_annotations = []
+        current_date = pd.Timestamp.now()
+        for _, annotation in self.others_annotations.iterrows():
+            annotation_date = pd.to_datetime(annotation.get('date', current_date))
+            if (current_date - annotation_date).days <= days_threshold:
+                new_annotations.append(annotation)
+        return new_annotations
+
+    def suggest_nearby_annotations(self):
+        """Suggérer des annotations à proximité des lieux annotés par l'utilisateur."""
+        nearby_annotations = []
+        for _, annotation in self.others_annotations.iterrows():
+            coords = self.parse_json(annotation['coords'])
+            annotation_location = {"lat": coords.get('lat'), "lon": coords.get('lon')}
+            lat_valid = annotation_location['lat'] is not None and pd.notna(annotation_location['lat'])
+            lon_valid = annotation_location['lon'] is not None and pd.notna(annotation_location['lon'])
+            if lat_valid and lon_valid:
+                if any(self.calculate_distance(annotation_location, user_loc) < 0.5 for user_loc in self.user_locations):
+                    nearby_annotations.append(annotation)
+        return nearby_annotations
+    
+    def suggest_annotations_based_on_interests(self, top_n=3):
+        """Suggérer les annotations les plus pertinentes basées sur les intérêts de l'utilisateur."""
+        interests = self.analyze_interests()
+        interests = interests['pca']  # Accéder aux intérêts PCA
+        annotations_with_matches = []
+        
+        for _, annotation in self.others_annotations.iterrows():
+            matches = 0
+            
+            # Vérification des correspondances de tags
+            tag_match_score = 0
+            total_tags_count = annotation['positive_tags_count'] + annotation['negative_tags_count'] + annotation['aesthetic_tags_count'] + annotation['social_tags_count']
+            if total_tags_count > 0:
+                for tag_type, interest_tag_count in interests['tag_usage'].items():
+                    annotation_tag_count = annotation[f'{tag_type}_tags_count'] if f'{tag_type}_tags_count' in annotation else 0
+                    # Si le nombre de tags de ce type dans l'annotation est supérieur à 0, il y a une correspondance.
+                    tag_match_score += (annotation_tag_count / total_tags_count) * interest_tag_count
+            
+            if tag_match_score > 0:
+                matches += tag_match_score
+            
+            # Vérification des correspondances d'icônes
+            icon_match_score = 0
+            total_icons_count = annotation['total_icons_count'] if 'total_icons_count' in annotation else 0
+            if total_icons_count > 0:
+                for icon_type, interest_icon_count in interests['icon_usage'].items():
+                    annotation_icon_count = annotation[f'{icon_type}_icons_count'] if f'{icon_type}_icons_count' in annotation else 0
+                    icon_match_score += (annotation_icon_count / total_icons_count) * interest_icon_count
+            
+            if icon_match_score > 0:
+                matches += icon_match_score
+            
+            # Vérification des types de lieux
+            place_types = self.parse_json(annotation['placeType'])  # Assurez-vous que cette méthode renvoie une liste
+            if any(place in interests['place_types'] for place in place_types):
+                matches += 1
+            
+            # Comparaison du volume lexical
+            if annotation['volume_lexical'] >= interests['avg_volume_lexical']:
+                matches += 1
+            
+            # Comparaison de la présence d'images
+            if annotation['graphical'] >= interests['image_presence_percentage']:
+                matches += 1
+            
+            # Ajout des annotations avec correspondances
+            if matches > 0:
+                annotations_with_matches.append((annotation, matches))
+        
+        # Trier les annotations en fonction du nombre de correspondances
+        annotations_with_matches.sort(key=lambda x: x[1], reverse=True)
+        
+        # Sélectionner les meilleures annotations
+        top_annotations = [annotation for annotation, _ in annotations_with_matches[:top_n]]
+        
+        return top_annotations
+
+
+
+    def correct_coordinates(self, lat, lon):
+        """
+        Corrige les coordonnées pour s'assurer qu'elles sont dans les plages valides.
+        
+        :param lat: Latitude à corriger
+        :param lon: Longitude à corriger
+        :return: Tuple contenant la latitude et la longitude corrigées
+        """
+        lat = max(min(lat, 90), -90)
+        lon = max(min(lon, 180), -180)
+        return lat, lon
+
+    def calculate_min_distance_to_tour(self, user_location, tour_coordinates):
+        """
+        Calcule la distance minimale entre la localisation de l'utilisateur et un parcours.
+
+        :param user_location: Dictionnaire contenant 'lat' et 'lon' pour la localisation de l'utilisateur.
+        :param tour_coordinates: Liste de tuples représentant les points du parcours [(lat, lon), (lat, lon), ...].
+        :return: Distance minimale en kilomètres.
+        """
+        min_distance = float('inf')
+        if not isinstance(user_location, dict) or 'lat' not in user_location or 'lon' not in user_location:
+            return None
+
+        for coord in tour_coordinates:
+            point = self.correct_coordinates(coord[1], coord[0])
+            distance = geodesic((user_location['lat'], user_location['lon']), point).kilometers
+            if distance < min_distance:
+                min_distance = distance
+
+        return min_distance
+
+    def is_within_proximity(self, tour_coordinates, user_location, proximity_threshold_km=5):
+        """
+        Vérifie si l'une des coordonnées du tour est à proximité de la localisation de l'utilisateur.
+
+        :param tour_coordinates: Liste de tuples représentant les points du parcours [(lat, lon), (lat, lon), ...].
+        :param user_location: Dictionnaire contenant 'lat' et 'lon' pour la localisation de l'utilisateur.
+        :param proximity_threshold_km: Distance maximale en kilomètres pour considérer le tour comme proche.
+        :return: True si le tour est à proximité de l'utilisateur, False sinon.
+        """
+        min_distance = self.calculate_min_distance_to_tour(user_location, tour_coordinates)
+        return min_distance is not None and min_distance <= proximity_threshold_km
+
+    def is_opportunity_for_user(self, tour):
+        """
+        Détermine si un tour représente une opportunité pour l'utilisateur.
+        
+        :param tour: Dictionnaire représentant un tour, avec une clé 'body' contenant les coordonnées du parcours.
+        :return: True si le tour représente une opportunité pour l'utilisateur, False sinon.
+        """
+        try:        
+            body = json.loads(tour['body'])
+        except json.JSONDecodeError as e:
+            print(f"Erreur lors de la conversion du corps du tour en liste de coordonnées: {e}")
+            return False
+
+        tour_coordinates = [(body[i+1], body[i]) for i in range(0, len(body), 2)]
+
+        for interest in self.user_interests:
+            if self.is_within_proximity(tour_coordinates, interest['location']):
+                return True
+
+        return False
+
+    def suggest_nearby_tours(self, proximity_threshold_km=5):
+        """
+        Suggère des tours qui sont à proximité des localisations de l'utilisateur.
+
+        :param user_locations: Liste de dictionnaires contenant des coordonnées de localisation de l'utilisateur.
+                              Ex: [{'lat': 48.8566, 'lon': 2.3522}, ...]
+        :param all_tours: Liste de dictionnaires représentant les tours, chaque dictionnaire ayant une clé 'body' 
+                          qui contient les coordonnées du parcours sous forme JSON.
+        :param proximity_threshold_km: Distance maximale en kilomètres pour considérer un tour comme proche.
+        :return: Liste des tours suggérés à proximité.
+        """
+        suggested_tours = []
+
+        for user_location in self.user_locations:
+            for tour in self.self.others_tours:
+                # Vérifier si le tour représente une opportunité pour l'utilisateur
+                if self.is_opportunity_for_user(tour):
+                    # Vérifier la proximité avec l'utilisateur
+                    if self.is_within_proximity(json.loads(tour['body']), user_location, proximity_threshold_km):
+                        suggested_tours.append(tour)
+
+        return suggested_tours
+    
+    def suggest_annotation_by_filter(self, top_n=3):
+        """
+        Suggérer les annotations qui correspondent le mieux aux filtres récurrents de l'utilisateur.
+
+        Args:
+            top_n (int): Nombre d'annotations à suggérer.
+
+        Returns:
+            list: Liste des annotations suggérées.
+        """
+        # Charger les motifs et les comptes des filtres
+        filters = json.loads(self.user_filters['motifs'])
+        annotations_with_scores = []
+
+        # Parcourir chaque annotation dans les annotations disponibles
+        for _, annotation in self.others_annotations.iterrows():
+            score = 0
+            
+            # Comparer les institutions, seulement celles qui sont récurrentes
+            if any(inst in filters['institutions'] for inst in annotation.get('institution', [])):
+                score += 1
+            
+            # Comparer les nationalités
+            if any(nat in filters['nationalities'] for nat in annotation.get('nationality', [])):
+                score += 1
+            
+            # Comparer les icônes
+            if any(icon in filters['icons'] for icon in annotation.get('icons', [])):
+                score += 1
+            
+            # Comparer les émoticônes
+            if any(emoticon in filters['emoticons'] for emoticon in annotation.get('emoticon', [])):
+                score += 1
+            
+            # Comparer les tags
+            if any(tag in filters['tags'] for tag in annotation.get('tags', [])):
+                score += 1
+            
+            # Comparer les plages de dates (facultatif, si pertinent)
+            begin_date = annotation.get('beginDate')
+            end_date = annotation.get('endDate')
+            date_range = f"{begin_date} - {end_date}" if begin_date and end_date else None
+            if date_range in filters['date_ranges']:
+                score += 1
+
+            # Ajouter l'annotation et son score si elle a un score positif
+            if score > 0:
+                annotations_with_scores.append((annotation, score))
+
+        # Trier les annotations par score, décroissant
+        annotations_with_scores.sort(key=lambda x: x[1], reverse=True)
+        
+        # Récupérer les top_n annotations
+        top_annotations = [annotation for annotation, _ in annotations_with_scores[:top_n]]
+        
+        return top_annotations
+    
+    def suggest_least_rich_annotation(self):
+        """
+        Suggérer l'annotation la moins riche de l'utilisateur, basée sur le score calculé par compute_annotation_score.
+        
+        Returns:
+            dict: L'annotation la moins riche ou None s'il n'y a pas d'annotations.
+        """
+        if not self.user_annotations:
+            return None
+        
+        least_rich_annotation = None
+        min_score = float('inf')
+        
+        # Parcourir les annotations et calculer leur score
+        for annotation in self.user_annotations:
+            score = self.compute_annotation_score(annotation)
+            
+            # Rechercher l'annotation avec le score le plus bas
+            if score < min_score:
+                min_score = score
+                least_rich_annotation = annotation
+
+        return least_rich_annotation
+
+    def get_unexplored_zones(self, min_distance=0.5):
+        """
+        Identifie les zones inexplorées autour des annotations existantes.
+        
+        :param user_id: ID de l'utilisateur.
+        :param annotations: Liste des coordonnées (lat, lon) des annotations de l'utilisateur.
+        :param min_distance: Distance minimale en kilomètres pour considérer une zone comme inexplorée.
+        :return: Liste de coordonnées représentant des zones inexplorées.
+        """
+        if self.user_annotations.empty:
+            return []
+        
+        coordinates = []
+        # coordinates = [(json.loads(ann['coords'])['lat'], json.loads(ann['coords'])['lon']) for ann in annotations]
+        
+        for index, row in self.user_annotations.iterrows():
+            coords = json.loads(row['coords'])
+            lat = coords['lat']
+            lon = coords['lon']
+            coordinates.append((lat, lon))
+
+        
+        # Clustering des annotations pour définir les zones explorées
+        clustering = DBSCAN(eps=min_distance, min_samples=5, metric=calculate_harv_distance).fit(coordinates)
+        
+        # Obtenir les clusters de zones explorées
+        explored_zones = [coordinates[i] for i in range(len(coordinates)) if clustering.labels_[i] != -1]
+        
+        # Déterminer les zones inexplorées (en dehors des clusters)
+        unexplored_zones = []
+        
+        for coord in coordinates:
+            is_far = all(calculate_harv_distance(coord, explored) > min_distance for explored in explored_zones)
+            if is_far:
+                unexplored_zones.append(coord)
+        
+        return unexplored_zones
+
+
+
+    def suggeste_uexplored_zone_annotation(self):
+            """
+            Récupère l'ID d'une annotation qui incite l'utilisateur à élargir sa couverture géographique.
+
+            :param user_id: ID de l'utilisateur.
+            :return: ID d'une annotation pertinente ou None.
+            """
+            #
+
+            if not self.unexplored_zones:
+                return None
+
+            # Pour chaque zone inexplorée, récupérer les annotations autour
+            for zone in self.unexplored_zones:
+                location = {'lat': zone[0], 'lon': zone[1]}
+                nearby_annotations = fetch_annotations_around_location(location, radius_km=5)
+                
+                if nearby_annotations:
+                    # Retourner l'ID de la première annotation trouvée à proximité
+                    return nearby_annotations[0][0]  # ID de l'annotation
+
+            return None
+
+
+
+    def execute_strategy(self, strategy):
+        """Exécuter la stratégie de suggestion demandée et retourner les résultats."""
+        if strategy == 'rich_annotations':
+            return self.suggest_rich_annotations()
+        elif strategy == 'popular_annotations':
+            return self.suggest_popular_annotations()
+        elif strategy == 'new_annotations':
+            return self.suggest_new_annotations()
+        elif strategy == 'nearby_annotations':
+            user_locations = [annotation['coords'] for annotation in self.user_annotations]
+            return self.suggest_nearby_annotations(user_locations)
+        elif strategy == 'annotations_based_on_interests':
+            return self.suggest_annotations_based_on_interests()
+        elif strategy == 'annotations_by_filters':
+            return self.suggest_annotation_by_filter()
+        elif strategy == 'least_rich_user_annotation':
+            return self.suggest_least_rich_annotation()
+        elif strategy == 'annotations_based_on_reactions':
+            return self.suggest_active_annotations()
+        elif strategy == 'nearby_tours':
+            user_locations = [annotation['coords'] for annotation in self.user_annotations]
+            return self.suggest_nearby_tours(user_locations)
+        elif strategy == 'uexplored_zone_annotation':
+            return self.suggeste_uexplored_zone_annotation()
+        else:
+            raise ValueError("Strategy not recognized")
+
+    def refine_suggestions(self, suggestions, filters):
+        """Affiner les suggestions selon les filtres fournis."""
+        refined_suggestions = []
+        for suggestion in suggestions:
+            match = True
+            for key, value in filters.items():
+                if key in suggestion and suggestion[key] != value:
+                    match = False
+                    break
+            if match:
+                refined_suggestions.append(suggestion)
+        return refined_suggestions
+
+    def suggest(self, strategies, filters=None):
+        """Point d'entrée pour les suggestions exécutant un pipeline de stratégies."""
+        filters = filters or {}
+        all_suggestions = []
+        
+        # Exécuter chaque stratégie et collecter les suggestions
+        for strategy in strategies:
+            suggestions = self.execute_strategy(strategy)
+            all_suggestions.extend(suggestions)
+
+        # Affiner les suggestions combinées selon les filtres fournis
+        refined_suggestions = self.refine_suggestions(all_suggestions, filters)
+        return refined_suggestions
\ No newline at end of file
diff --git a/rs/modules/user_profile.py b/rs/modules/user_profile.py
new file mode 100644
index 0000000000000000000000000000000000000000..87a57c2f2c9dfe15067ffc68c012265b92a3059b
--- /dev/null
+++ b/rs/modules/user_profile.py
@@ -0,0 +1,355 @@
+from datetime import datetime
+import json
+
+import pandas as pd
+from modules.db_operations import fetch_annotations_by_id, fetch_user_tours, fetch_user_annotations
+from modules.es_operations import fetch_user_ids, fetch_user_viewed_annotations
+from web_app import db
+from web_app.models import UserProfile
+
+
+def calculate_covered_zones(annotations):
+    from geopy.distance import geodesic
+    
+    if annotations.empty:
+        return 0  # Retourne 0 si le DataFrame est vide
+
+    ZONE_RADIUS = 5.0  # Rayon en kilomètres pour regrouper les points géographiquement proches
+    
+    zones = []
+    for _, annotation in annotations.iterrows():
+        # Assurez-vous que 'coords' est bien un dictionnaire
+        coords = annotation['coords']
+        if isinstance(coords, str):
+            coords = eval(coords)  # Convertir la chaîne en dictionnaire si nécessaire
+        
+        coord = (coords['lat'], coords['lon'])
+        matched_zone = False
+        for zone in zones:
+            if geodesic(coord, zone).km <= ZONE_RADIUS:
+                matched_zone = True
+                break
+        if not matched_zone:
+            zones.append(coord)
+    
+    return len(zones)
+
+
+
+def calculate_profile_metrics(annotations, prefix):
+    # Structure par défaut pour les valeurs manquantes ou nulles
+    default_structure = {
+        f'{prefix}items_count': 0,
+        f'{prefix}avg_volume_lexical': 0,
+        f'{prefix}avg_icons_per_annotation': 0,
+        f'{prefix}avg_types_icons': 0,
+        f'{prefix}icon_type_ratio': {
+            'senses': 0,
+            'social': 0,
+            'memory': 0,
+            'activity': 0,
+            'environment': 0,
+            'mission': 0,
+        },
+        f'{prefix}avg_tags_per_annotation': 0,
+        f'{prefix}tag_type_ratio': {
+            'positive': 0,
+            'negative': 0,
+            'aesthetic': 0,
+            'social': 0,
+        },
+        f'{prefix}emoticon_top3': [None, None, None],
+        f'{prefix}emoticonality_avg': 0.0,
+        f'{prefix}place_type_usage': {
+            'here': 0,
+            'close': 0,
+            'district': 0,
+            'city': 0,
+            'tour': 0
+        },
+        f'{prefix}experience_frequency': {
+            'UNIQUE': 0,
+            'REGULAR': 0,
+            'OCCASIONAL': 0
+        },
+        f'{prefix}covered_zones': None,
+    }
+    
+    # Si aucune annotation, retourne la structure par défaut
+    if len(annotations) == 0:
+        print('No annotations found')
+        return default_structure
+
+    items_count = len(annotations)
+    print(f'User has {items_count} annotations')
+
+    # Calculs des métriques
+    avg_volume_lexical = annotations['volume_lexical'].mean() if 'volume_lexical' in annotations and not annotations['volume_lexical'].isnull().all() else 0
+    avg_icons_per_annotation = annotations['total_icons_count'].mean() if 'total_icons_count' in annotations and not annotations['total_icons_count'].isnull().all() else 0
+    avg_types_icons = annotations['types_icons_count'].mean() if 'types_icons_count' in annotations and not annotations['types_icons_count'].isnull().all() else 0
+
+    # Initialisation des ratios d'icônes avec les valeurs par défaut
+    total_icons_by_type = {
+        'senses': 0,
+        'social': 0,
+        'memory': 0,
+        'activity': 0,
+        'environment': 0,
+        'mission': 0,
+    }
+    
+    # Calcul des icônes par type si les colonnes existent
+    for icon_type in total_icons_by_type.keys():
+        icon_column = f'{icon_type}_icons_count'
+        if icon_column in annotations:
+            total_icons_by_type[icon_type] = annotations[icon_column].sum()
+
+    # Calcul du ratio d'icônes
+    total_icons_count = annotations['total_icons_count'].sum() if 'total_icons_count' in annotations else 1
+    icon_type_ratio = {
+        k: (v / total_icons_count * 100 if total_icons_count > 0 else 0)
+        for k, v in total_icons_by_type.items()
+    }
+
+    # Calcul des ratios de tags
+    total_tags_count = annotations['total_tags_count'].sum() if 'total_tags_count' in annotations else 1
+    tag_type_ratio = {
+        'positive': 0,
+        'negative': 0,
+        'aesthetic': 0,
+        'social': 0,
+    }
+    for tag_type in tag_type_ratio.keys():
+        tag_column = f'{tag_type}_tags_count'
+        if tag_column in annotations:
+            tag_type_ratio[tag_type] = annotations[tag_column].sum() / total_tags_count * 100 if total_tags_count > 0 else 0
+
+    # Calcul des émoticônes
+    emoticons = annotations['emoticon'].dropna().tolist() if 'emoticon' in annotations else []
+    emoticon_top3 = sorted(set(emoticons), key=emoticons.count, reverse=True)[:3] if emoticons else [None, None, None]
+    emoticonality_avg = annotations['emoticonality'].mean() * 100 if 'emoticonality' in annotations and not annotations['emoticonality'].isnull().all() else 0
+
+    # Calcul de l'utilisation des types de lieux (placeType)
+    place_type_usage = {
+        'here': 0,
+        'close': 0,
+        'district': 0,
+        'city': 0,
+        'tour': 0
+    }
+    if 'placeType' in annotations:
+        place_type_counts = annotations['placeType'].value_counts(normalize=True) * 100
+        for place in place_type_usage.keys():
+            if place in place_type_counts:
+                place_type_usage[place] = place_type_counts[place]
+
+    # Calcul de la fréquence d'expérience (timing)
+    experience_frequency = {
+        'UNIQUE': 0,
+        'REGULAR': 0,
+        'OCCASIONAL': 0
+    }
+    if 'timing' in annotations:
+        timing_counts = annotations['timing'].value_counts(normalize=True) * 100
+        for timing in experience_frequency.keys():
+            if timing in timing_counts:
+                experience_frequency[timing] = timing_counts[timing]
+
+    # Calcul des zones couvertes
+    covered_zones = calculate_covered_zones(annotations)
+
+    # Retour des métriques calculées avec les préfixes
+    return {
+        f'{prefix}items_count': items_count,
+        f'{prefix}avg_volume_lexical': avg_volume_lexical,
+        f'{prefix}avg_icons_per_annotation': avg_icons_per_annotation,
+        f'{prefix}avg_types_icons': avg_types_icons,
+        f'{prefix}icon_type_ratio': icon_type_ratio,
+        f'{prefix}avg_tags_per_annotation': annotations['total_tags_count'].mean() if 'total_tags_count' in annotations and not annotations['total_tags_count'].isnull().all() else 0,
+        f'{prefix}tag_type_ratio': tag_type_ratio,
+        f'{prefix}emoticon_top3': emoticon_top3,
+        f'{prefix}emoticonality_avg': emoticonality_avg,
+        f'{prefix}place_type_usage': place_type_usage,
+        f'{prefix}experience_frequency': experience_frequency,
+        f'{prefix}covered_zones': covered_zones,
+    }
+
+
+
+def calculate_pca(user_id):
+    annotations = fetch_user_annotations(user_id)
+    return calculate_profile_metrics(annotations, prefix='pca_')
+
+def calculate_pcoa(user_id):
+    ids = fetch_user_viewed_annotations(user_id)
+    annotations =  fetch_annotations_by_id(ids)
+    return calculate_profile_metrics(annotations, prefix='pcoa_')
+
+import json
+
+def calculate_tour_metrics(tours, prefix):
+    total_points = 0
+    total_annotations = 0
+    text_lengths = []
+    zones_covered = set()  # Set pour éviter les doublons de zones
+    tour_types = {}
+    annotation_frequency = {}
+
+    for index, tour in tours.iterrows():
+        coords = tour['body']  # Liste des coordonnées [lon1, lat1, lon2, lat2, ...]
+        # Si coords est une chaîne de caractères, la convertir en liste
+        if isinstance(coords, str):
+            try:
+                coords = json.loads(coords)  # Supposons que c'est du JSON
+            except json.JSONDecodeError:
+                # Si la chaîne n'est pas du JSON, la transformer manuellement si nécessaire
+                coords = list(map(float, coords.strip('[]').split(',')))
+
+        num_points = len(coords) // 2  # Chaque point est une paire [lon, lat]
+        total_points += num_points
+        
+        # Ajouter les zones couvertes par les coordonnées
+        for i in range(0, len(coords), 2):
+            zones_covered.add((coords[i], coords[i + 1]))
+
+        # Compter les types de parcours (share)
+        tour_type = tour['share']
+        if tour_type in tour_types:
+            tour_types[tour_type] += 1
+        else:
+            tour_types[tour_type] = 1
+
+        # Calcul de la longueur de texte des messages associés
+        if 'messages' in tour and tour['messages']:
+            text_lengths.append(len(tour['messages']))
+
+        # Fréquence des annotations
+        if 'annotations' in tour and tour['annotations']:
+            try:
+                # Si les annotations sont une chaîne de caractères JSON, on les désérialise
+                if isinstance(tour['annotations'], str):
+                    annotations = json.loads(tour['annotations'])
+                else:
+                    annotations = tour['annotations']
+                
+                # Compter les annotations
+                total_annotations += len(annotations)
+                for annotation in annotations:
+                    if annotation in annotation_frequency:
+                        annotation_frequency[annotation] += 1
+                    else:
+                        annotation_frequency[annotation] = 1
+            except json.JSONDecodeError:
+                print(f"Annotations malformées dans la ligne {index}: {tour['annotations']}")
+    
+    avg_points = total_points / len(tours) if len(tours) > 0 else 0
+    point_density = total_points / len(zones_covered) if zones_covered else 0
+    avg_volume_lexical = sum(text_lengths) / len(text_lengths) if text_lengths else 0
+    tour_type_proportion = {k: v / len(tours) for k, v in tour_types.items()} if len(tours) > 0 else {}
+    items_count = len(tours)
+
+    print('annotation_frequency: ', annotation_frequency)
+
+    
+    return {
+        f'{prefix}items_count': items_count,
+        f'{prefix}avg_points': avg_points,
+        f'{prefix}point_density': point_density,
+        f'{prefix}frequent_geo_zones': list(zones_covered),  # Convertir en liste
+        f'{prefix}tour_type_proportion': tour_type_proportion,
+        f'{prefix}avg_volume_lexical': avg_volume_lexical,
+        f'{prefix}annotation_frequency': annotation_frequency,
+        f'{prefix}covered_zones': list(zones_covered),
+    }
+
+def calculate_pcp(user_id):
+    paths = fetch_user_tours(user_id)
+    if paths.empty:
+        return {
+            'pcp_items_count': 0,
+            'pcp_avg_points': 0,
+            'pcp_point_density': 0,
+            'pcp_frequent_geo_zones': [],  # Convertir en liste
+            'pcp_tour_type_proportion': {},
+            'pcp_avg_volume_lexical': 0,
+            'pcp_annotation_frequency': {},
+            'pcp_covered_zones': [],
+        }
+
+    # Utiliser la fonction commune pour calculer les métriques avec préfixe 'pcp_'
+    return calculate_tour_metrics(paths, prefix='pcp_')
+
+def calculate_pcop(user_id):
+    # Récupérer uniquement les parcours consultés
+    paths = fetch_user_tours(user_id)
+    if paths.empty:
+        return {
+            'pcop_items_count': 0,
+            'pcop_avg_points': 0,
+            'pcop_point_density': 0,
+            'pcop_frequent_geo_zones': [],  # Convertir en liste
+            'pcop_tour_type_proportion': {},
+            'pcop_avg_volume_lexical': 0,
+            'pcop_annotation_frequency': {},
+            'pcop_covered_zones': [],
+        }
+
+    # Utiliser la fonction commune pour calculer les métriques avec préfixe 'pcop_'
+    return calculate_tour_metrics(paths, prefix='pcop_')
+
+
+def save_user_profile(user_id, pca, pcoa, pcp, pcop):
+    profile = UserProfile.query.get(user_id)
+    if not profile:
+        profile = UserProfile(user_id=user_id)
+    
+    # Dictionnaires des données à combiner
+    data_sources = {
+        'pca': pca,
+        'pcoa': pcoa,
+        'pcp': pcp,
+        'pcop': pcop
+    }
+    
+    # Parcourir chaque source de données et mettre à jour le profil
+    for prefix, data in data_sources.items():
+        for key, value in data.items():
+            if value is not None:  # Vérifiez que la valeur n'est pas nulle
+                # print(f"Setting {key} to {value}") 
+                setattr(profile, f"{key}", value)
+    
+    # Sauvegarder le profil dans la base de données
+    db.session.add(profile)
+    db.session.commit()
+
+    # print('Complete profile:', profile.to_dict())
+
+    return profile
+
+
+
+
+def calculate_users_profiles():
+    user_ids = fetch_user_ids()
+    # Pour tester
+    for user in user_ids:  
+        # Calculer PCA
+        pca = calculate_pca(user)
+        # print('PCA for user {}: {}'.format(user, pca))
+        # return
+        
+        # Calculer PCoA
+        pcoa = calculate_pcoa(user)
+        # print('PCoA for user {}: {}'.format(user, pcoa))
+        
+        # Calculer PCP
+        pcp = calculate_pcp(user)
+        # print('PCP for user {}: {}'.format(user, pcp))
+        
+        # Calculer PCoP
+        pcop = calculate_pcop(user)
+        # print('PCoP for user {}: {}'.format(user, pcop))
+        
+        # Enregistrer ou mettre à jour le profil utilisateur
+        save_user_profile(user, pca, pcoa, pcp, pcop)
+
diff --git a/rs/requirements.txt b/rs/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..17a10d46a6ebf6fb496dc3eb17d0c88c2db8caa0
--- /dev/null
+++ b/rs/requirements.txt
@@ -0,0 +1,19 @@
+Flask
+Flask-SQLAlchemy
+psycopg2-binary
+requests
+apscheduler
+python-dateutil
+pandas
+geopy
+elasticsearch
+scikit-learn
+matplotlib
+seaborn
+numpy
+SQLAlchemy  
+pymysql
+nltk
+shapely
+geopandas
+spacy
\ No newline at end of file
diff --git a/rs/run.py b/rs/run.py
new file mode 100644
index 0000000000000000000000000000000000000000..7561ab077076c89e24cad42f5ac127c35cbff615
--- /dev/null
+++ b/rs/run.py
@@ -0,0 +1,8 @@
+import logging
+from web_app import create_app
+
+app = create_app()
+
+if __name__ == '__main__':
+    app.run(host="0.0.0.0", port=8080, debug=True, use_reloader=True)
+    # app.logger.setLevel(logging.DEBUG)
diff --git a/rs/web_app/__init__.py b/rs/web_app/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..31d27d74dd09c5275664254216b48bd722821050
--- /dev/null
+++ b/rs/web_app/__init__.py
@@ -0,0 +1,50 @@
+import logging
+from flask import Flask
+from flask_sqlalchemy import SQLAlchemy
+from apscheduler.schedulers.background import BackgroundScheduler
+from config.settings import Config
+
+db = SQLAlchemy()
+scheduler = BackgroundScheduler()
+
+def create_app():
+    print("create_app est appelé")
+    app = Flask(__name__)
+    app.config.from_object(Config)
+
+    # Configuration de la journalisation
+    app.logger.setLevel(logging.DEBUG)
+
+    # Ajout de la clé secrète pour la gestion des sessions
+    app.secret_key = 'madjidus'  # Remplacez par une clé aléatoire unique et sécurisée
+
+    # Initialiser les extensions
+    db.init_app(app)
+
+    with app.app_context():
+        # Importez tous les modèles ici
+        from web_app.models import Indicator, Recommendation, Impact, ScheduledTask, ExecutionLog
+        
+        # Créer les tables si elles n'existent pas encore
+        try:
+            db.create_all()
+            app.logger.info("Toutes les tables ont été créées avec succès.")
+        except Exception as e:
+            app.logger.error(f"Erreur lors de la création des tables: {e}")
+
+        # Importer et enregistrer les routes après avoir initialisé db
+        from web_app.routes import bp as web_bp
+        app.register_blueprint(web_bp)
+
+        # Ajouter les tâches planifiées
+        from web_app.tasks import add_scheduled_jobs
+        add_scheduled_jobs()
+
+    # Démarrer le planificateur si ce n'est pas déjà fait
+    if not scheduler.running:
+        scheduler.start()
+
+    return app
+
+# Créer l'application Flask
+app = create_app()
diff --git a/rs/web_app/app.py b/rs/web_app/app.py
new file mode 100644
index 0000000000000000000000000000000000000000..506731cbf5064b17ad524636ee0a710f1d7b8c02
--- /dev/null
+++ b/rs/web_app/app.py
@@ -0,0 +1,14 @@
+# web_app/app.py
+
+from flask import Flask
+
+def create_app():
+    app = Flask(__name__)
+    app.config.from_pyfile('../config/settings.py')
+   
+
+    # Charger les routes de l'application
+    from . import routes
+    app.register_blueprint(routes.bp)
+
+    return app
diff --git a/rs/web_app/models.py b/rs/web_app/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..db181f1f902ba66d4be5b5b8d53800d50bf47a46
--- /dev/null
+++ b/rs/web_app/models.py
@@ -0,0 +1,170 @@
+from datetime import datetime
+
+from sqlalchemy import func
+from web_app import db
+
+class Indicator(db.Model):
+    __tablename__ = 'indicators'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    user_id = db.Column(db.Integer)
+    category = db.Column(db.String(255))
+    strategy = db.Column(db.String(255))
+    type = db.Column(db.String(255))
+    value = db.Column(db.Text)
+    date = db.Column(db.DateTime, default=datetime.utcnow)
+
+    def __repr__(self):
+        return f'<Indicator {self.id}>'
+
+
+
+class Recommendation(db.Model):
+    __tablename__ = 'recommendations'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    user_id = db.Column(db.Integer)
+    category = db.Column(db.String(255))
+    strategy = db.Column(db.String(255))
+    title = db.Column(db.String(255))
+    recommendation = db.Column(db.String(1020))
+    suggestion = db.Column(db.String(255))
+    suggestion_type = db.Column(db.String(255))
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)
+    
+    # Appliquer une valeur par défaut au niveau du serveur de base de données
+    status = db.Column(db.String(20), default='Created', server_default='Created')  
+    
+    status_updated_at = db.Column(db.DateTime, nullable=True, default=func.now())
+
+    def __repr__(self):
+        return f'<Recommendation {self.id}>'
+
+
+from datetime import datetime
+from flask_sqlalchemy import SQLAlchemy
+
+
+
+class UserProfile(db.Model):
+    __tablename__ = 'user_profiles'
+    
+    user_id = db.Column(db.Integer, primary_key=True)  # Lien vers la table des utilisateurs
+
+    # Profils d'Annotation (PCA)
+    pca_items_count  = db.Column(db.Float, default=0.0)  # Nombre d'annotations créées
+    pca_avg_volume_lexical = db.Column(db.Float, default=0.0)  # Longueur moyenne du texte des annotations créées
+    pca_avg_icons_per_annotation = db.Column(db.Float, default=0.0)  # Nombre moyen d'icônes par annotation
+    pca_avg_tags_per_annotation = db.Column(db.Float, default=0.0)  # Nombre moyen de tags par annotation
+    pca_emoticon_top3 = db.Column(db.JSON)  # Top 3 des émoticônes les plus utilisées
+    pca_image_presence_percentage = db.Column(db.Float, default=0.0)  # Pourcentage d'annotations contenant des images
+    pca_place_type_usage = db.Column(db.JSON)  # Proportion des types de lieux annotés
+    pca_experience_frequency = db.Column(db.JSON)  # Fréquence des expériences annotées
+    pca_covered_zones = db.Column(db.JSON)  # Zones couvertes par les annotations
+    pca_avg_types_icons = db.Column(db.Float, default=0.0)  # Nombre moyen de types d'icônes utilisés par annotation
+    pca_icon_type_ratio = db.Column(db.JSON)  # Ratio des types d'icônes utilisés
+    pca_tag_type_ratio = db.Column(db.JSON)  # Ratio des types de tags utilisés
+
+    # Profils de Consultation d'Annotation (PCoA)
+    pcoa_items_count  = db.Column(db.Float, default=0.0)  # Nombre d'annotations visitées
+    pcoa_avg_volume_lexical = db.Column(db.Float, default=0.0)  # Longueur moyenne du texte des annotations visitées
+    pcoa_avg_icons_per_annotation = db.Column(db.Float, default=0.0)  # Nombre moyen d'icônes par annotation visitée
+    pcoa_avg_tags_per_annotation = db.Column(db.Float, default=0.0)  # Nombre moyen de tags par annotation visitée
+    pcoa_emoticon_top3 = db.Column(db.JSON)  # Top 3 des émoticônes les plus utilisées lors des visites
+    pcoa_image_presence_percentage = db.Column(db.Float, default=0.0)  # Pourcentage d'annotations consultées contenant des images
+    pcoa_place_type_usage = db.Column(db.JSON)  # Proportion des types de lieux visités
+    pcoa_experience_frequency = db.Column(db.JSON)  # Fréquence des types d'expériences consultées
+    pcoa_covered_zones = db.Column(db.JSON)  # Zones couvertes par les annotations visitées
+    pcoa_recurrent_motifs = db.Column(db.JSON)  # Motifs de filtres récurrents lors des consultations
+    pcoa_avg_types_icons = db.Column(db.Float, default=0.0)  # Nombre moyen de types d'icônes par annotation visitée
+    pcoa_icon_type_ratio = db.Column(db.JSON)  # Ratio des types d'icônes consultés
+    pcoa_tag_type_ratio = db.Column(db.JSON)  # Ratio des types de tags consultés
+
+    # Profils de Création de Parcours (PCP)
+    pcp_items_count  = db.Column(db.Float, default=0.0)  # Nombre de parcours créés
+    pcp_avg_points = db.Column(db.Float, default=0.0)  # Nombre moyen de points par parcours créé
+    pcp_point_density = db.Column(db.Float, default=0.0)  # Densité géographique des points créés
+    pcp_frequent_geo_zones = db.Column(db.JSON)  # Zones géographiques fréquentes dans les parcours créés
+    pcp_path_type_proportion = db.Column(db.JSON)  # Proportion des types de parcours créés (ex: GPS, manuel)
+    pcp_avg_volume_lexical = db.Column(db.Float, default=0.0)  # Longueur moyenne du texte descriptif des parcours créés
+    pcp_annotation_frequency = db.Column(db.JSON)  # Fréquence des annotations associées aux parcours créés
+    pcp_covered_zones = db.Column(db.JSON)  # Zones couvertes par les parcours créés
+    pcp_avg_types_icons = db.Column(db.Float, default=0.0)  # Nombre moyen de types d'icônes dans les parcours créés
+
+    # Profils de Consultation de Parcours (PCoP)
+    pcop_items_count  = db.Column(db.Float, default=0.0)  # Nombre de parcours visités
+    pcop_avg_points = db.Column(db.Float, default=0.0)  # Nombre moyen de points par parcours visité
+    pcop_point_density = db.Column(db.Float, default=0.0)  # Densité géographique des points consultés
+    pcop_frequent_geo_zones = db.Column(db.JSON)  # Zones géographiques fréquentes dans les parcours visités
+    pcop_path_type_proportion = db.Column(db.JSON)  # Proportion des types de parcours visités (ex: GPS, manuel)
+    pcop_avg_volume_lexical = db.Column(db.Float, default=0.0)  # Longueur moyenne du texte descriptif des parcours visités
+    pcop_annotation_frequency = db.Column(db.JSON)  # Fréquence des annotations associées aux parcours consultés
+    pcop_covered_zones = db.Column(db.JSON)  # Zones couvertes par les parcours consultés
+    pcop_avg_types_icons = db.Column(db.Float, default=0.0)  # Nombre moyen de types d'icônes dans les parcours visités
+
+    # Métadonnées
+    created_at = db.Column(db.DateTime, default=datetime.utcnow)  # Date de création du profil
+    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)  # Date de dernière mise à jour du profil
+
+    def __repr__(self):
+        return f"<UserProfile {self.user_id}: {self.to_dict()}>"
+    
+    def to_dict(self):
+        return {column.name: getattr(self, column.name) for column in self.__table__.columns}
+
+    
+class Impact(db.Model):
+    __tablename__ = 'impacts'
+    
+    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+    user_id = db.Column(db.Integer)
+    category = db.Column(db.String(255))
+    strategy = db.Column(db.String(255))
+    analysis_type = db.Column(db.String(255))
+    result = db.Column(db.Float)
+    date = db.Column(db.DateTime, default=datetime.utcnow)
+
+    def __repr__(self):
+        return f'<Impact {self.id}>'
+
+
+class ScheduledTask(db.Model):
+    __tablename__ = 'scheduled_task'
+
+    id = db.Column(db.Integer, primary_key=True)
+    name = db.Column(db.String(50), nullable=False)
+    action = db.Column(db.String(50), nullable=False)
+    scheduled_time = db.Column(db.DateTime, nullable=False)
+    creation_time = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
+    status = db.Column(db.String(20), default='Not Executed')
+    last_run = db.Column(db.DateTime, nullable=True)
+    recurrence = db.Column(db.String(50), nullable=True)  # Added for recurrence
+
+    def __repr__(self):
+        return f'<ScheduledTask {self.name}>'
+
+class ExecutionLog(db.Model):
+    __tablename__ = 'execution_logs'
+
+    id = db.Column(db.Integer, primary_key=True)
+    action_type = db.Column(db.String(255), nullable=False)  # Type d'action exécutée
+    status = db.Column(db.String(50), nullable=False)  # Statut de l'exécution (e.g., success, failed)
+    start_time = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)  # Début de l'exécution
+    end_time = db.Column(db.DateTime, nullable=True)  # Fin de l'exécution
+    duration = db.Column(db.Float, nullable=True)  # Durée de l'exécution en secondes
+    details = db.Column(db.Text, nullable=True)  # Détails supplémentaires (e.g., erreurs, messages)
+
+    def __init__(self, action_type, status, start_time=None, end_time=None, duration=None, details=None):
+        self.action_type = action_type
+        self.status = status
+        self.start_time = start_time or datetime.utcnow()
+        self.end_time = end_time
+        self.duration = duration
+        self.details = details
+
+    def mark_complete(self, status, details=None):
+        self.end_time = datetime.utcnow()
+        self.duration = (self.end_time - self.start_time).total_seconds()
+        self.status = status
+        self.details = details
+
diff --git a/rs/web_app/routes.py b/rs/web_app/routes.py
new file mode 100644
index 0000000000000000000000000000000000000000..878f54d0bb72f160c53a58d27ba6a5a603a40292
--- /dev/null
+++ b/rs/web_app/routes.py
@@ -0,0 +1,451 @@
+import ipaddress
+import json
+import traceback
+
+import requests
+
+from flask import Blueprint, jsonify, render_template, redirect, url_for, request, session, flash
+from modules.compute_metrics import calculate_metrics
+from modules.db_operations import fetch_category_indicators, fetch_category_recommendations, get_distant_annotations, get_distant_messages, get_distant_tours, get_distant_users, save_annotations_to_local_database, save_messages_to_local_database, save_tours_to_local_database, save_users_to_local_database
+from modules.notification_service import send_recommendation_notifications
+from modules.preprocessing.preprocess_annotations import preprocess_annotations
+from modules.recommendations.adoption_integration_rec import generate_adoption_integration_recommendations
+from modules.recommendations.content_quality_rec import generate_content_quality_recommendations
+from modules.recommendations.engagement_reengagement_rec import generate_all_engagement_recommendations
+from modules.recommendations.interaction_reflection_rec import generate_all_reflection_recommendations
+from modules.recommendations.urban_discovery_rec import generate_all_urban_recommendations
+from modules.user_profile import calculate_users_profiles
+from web_app.models import ExecutionLog, ScheduledTask
+from web_app import db
+from datetime import datetime
+import pytz
+from web_app.tasks import delete_all_scheduled_tasks, execute_task_function, add_scheduled_jobs, reload_scheduled_jobs
+from modules.generate_recommendations import (
+                verifier_initiation_integration_utilisateur,
+                verifier_engagement_utilisateur,
+                verifier_qualite_contributions,
+                verifier_interaction_reflexion
+            )
+
+bp = Blueprint('web_app', __name__)
+
+def get_or_create_log(action_type):
+    """
+    Récupère un enregistrement de journalisation existant pour l'action spécifiée, ou en crée un nouveau.
+    """
+    log = ExecutionLog.query.filter_by(action_type=action_type, status='in_progress').first()
+    if not log:
+        log = ExecutionLog(action_type=action_type, status='in_progress')
+        db.session.add(log)
+        db.session.commit()
+    return log
+
+def update_log(log, status, details=None):
+    """
+    Met à jour l'enregistrement de journalisation avec le statut et les détails spécifiés.
+    """
+    log.status = status
+    log.details = details
+    log.end_time = datetime.utcnow()
+    db.session.commit()
+
+def execute_task_by_action(task_action):
+    """
+    Exécute la tâche spécifiée par `task_action` et enregistre les logs d'exécution.
+    """
+    log = get_or_create_log(task_action)
+
+    try:
+        if task_action == 'prepare_data':
+            print('Getting users data')
+            users = get_distant_users()
+            save_users_to_local_database(users)
+
+            print('Getting and preprocessing annotation data')
+            annotations = get_distant_annotations()
+            preprocess_annotations(annotations)
+            save_annotations_to_local_database(annotations)
+
+            print('Getting tours data')
+            tours = get_distant_tours()
+            save_tours_to_local_database(tours)
+
+            print('Getting messages data')
+            messages = get_distant_messages()
+            save_messages_to_local_database(messages)
+            # Exécute les métriques pour toutes les catégories
+            calculate_users_profiles() 
+
+        elif task_action == 'fetch_raw_data':
+            print('Getting users data')
+            users = get_distant_users()
+            save_users_to_local_database(users)
+
+            print('Getting and preprocessing annotation data')
+            annotations = get_distant_annotations()
+            preprocess_annotations(annotations)
+            save_annotations_to_local_database(annotations)
+
+            print('Getting tours data')
+            tours = get_distant_tours()
+            save_tours_to_local_database(tours)
+
+            print('Getting messages data')
+            messages = get_distant_messages()
+            save_messages_to_local_database(messages)
+        
+         # Exécute les métriques pour toutes les catégories
+        elif task_action == 'construct_user_profiles':
+            calculate_users_profiles() 
+        
+         # Exécute les métriques pour toutes les catégories
+        elif task_action == 'calculate_metrics':
+            calculate_metrics() 
+        
+        # Cas pour chaque catégorie spécifique
+        elif task_action == 'calculate_adoption_metrics':
+            calculate_metrics(category='adoption')
+        
+        elif task_action == 'calculate_engagement_metrics':
+            calculate_metrics(category='engagement')
+        
+        elif task_action == 'calculate_quality_metrics':
+            calculate_metrics(category='quality')
+        
+        elif task_action == 'calculate_urban_discovery_metrics':
+            calculate_metrics(category='urbain')
+        
+        elif task_action == 'calculate_interaction_reflection_metrics':
+            calculate_metrics(category='reflexion')
+        
+        elif task_action == 'initiation_integration':
+            # verifier_initiation_integration_utilisateur()
+            generate_adoption_integration_recommendations()
+        elif task_action == 'generate_recommendations':
+            generate_adoption_integration_recommendations()
+            generate_all_engagement_recommendations()
+            generate_content_quality_recommendations()
+            generate_all_urban_recommendations()
+
+        
+        elif task_action == 'engagement_promotion':
+            generate_all_engagement_recommendations()
+        
+        elif task_action == 'quality_improvement':
+            generate_content_quality_recommendations()
+        
+        elif task_action == 'urban_discovery':
+            generate_all_urban_recommendations()
+        
+        elif task_action == 'interaction_reflection':
+            generate_all_reflection_recommendations()
+        
+        elif task_action == 'send_notifications':
+            send_recommendation_notifications()
+
+        elif task_action == 'full_process':        
+            # 1. Récupération des données
+            users = get_distant_users()
+            save_users_to_local_database(users)
+            annotations = get_distant_annotations()
+            preprocess_annotations(annotations)
+            save_annotations_to_local_database(annotations)
+
+            # 2. Calcul des métriques
+            calculate_metrics()
+
+            # 3. Génération de recommandations
+            
+            generate_adoption_integration_recommendations()
+            generate_all_engagement_recommendations()
+            generate_content_quality_recommendations()
+            generate_all_urban_recommendations()
+
+            # 4. Envoi de notifications
+            send_recommendation_notifications()
+        
+        else:
+            update_log(log, status='failed', details='Unknown task action.')
+            return jsonify({'success': False, 'message': 'Unknown task action.'}), 400
+
+        update_log(log, status='success')
+        return jsonify({'success': True, 'message': 'Task executed successfully.'})
+    
+    except Exception as e:
+        update_log(log, status='failed', details=str(e))
+        print("Une erreur est survenue :")
+        print(str(e))
+        print("Détails de l'erreur :")
+        traceback.print_exc()
+        return jsonify({'success': False, 'message': str(e)}), 500
+
+
+# Définir le nombre maximal de tentatives
+MAX_ATTEMPTS = 3
+REDIRECT_URL = 'https://mobiles-projet.huma-num.fr/' 
+API_KEY = "248b457a71fcd0"
+# Liste des plages IP autorisées (plages internes et privées)
+ALLOWED_IP_RANGES = [
+    ipaddress.ip_network('127.0.0.0/8'),  # Plage privée
+    ipaddress.ip_network('10.0.0.0/8'),  # Plage privée
+    ipaddress.ip_network('192.168.0.0/16'),  # Plage privée
+    ipaddress.ip_network('172.16.0.0/12')  # Plage privée
+]
+# Fonction pour vérifier si l'adresse IP est dans les plages autorisées
+def is_ip_allowed(ip):
+    ip_obj = ipaddress.ip_address(ip)
+    return any(ip_obj in range_ for range_ in ALLOWED_IP_RANGES)
+
+def check_access(ip_address):
+    """
+    Vérifie si l'adresse IP est autorisée.
+    """
+    print('tentative de connexion depuis: ', ip_address)
+    return is_ip_allowed(ip_address)
+
+def full_check_access(ip_address):
+    """
+    Vérifie si l'adresse IP est autorisée.
+    """
+    response = requests.get(f'https://ipinfo.io/{ip_address}/json?token={API_KEY}')
+    data = response.json()
+    
+    # Vérifiez le pays
+    country_code = data.get('country')
+    print('tentative de connexion depuis: ', data)
+    return country_code == 'FR' or is_ip_allowed(ip_address)
+
+@bp.route('/login',  methods=['GET', 'POST'])
+def login():
+    # Obtenez l'adresse IP de l'utilisateur
+    ip_address = request.remote_addr
+    # Vérifiez si l'IP est en France
+    if not check_access(ip_address):
+        flash('L\'accès est restreint aux membres du projet.', 'error')
+        print('Accès refusée pour :', ip_address)
+        return redirect(url_for('web_app.show_login'))
+    code = request.form.get('code')
+    
+    # Vérifier si le code est correct (remplacer par la logique de validation réelle)
+    if code == 'mobilespassword':  # Remplacez par la logique de validation réelle
+        session['authenticated'] = True
+        session.pop('attempts', None)  # Réinitialiser le compteur de tentatives
+        print('Accès autorisé pour :', ip_address)
+        return redirect(url_for('web_app.dashboard'))
+    else:
+        print('Tentative avec code erroné depuis :', ip_address,'. Code utilisé: ',code)
+    # Gérer les tentatives échouées
+    attempts = session.get('attempts', 0) + 1
+    session['attempts'] = attempts
+    
+    if attempts >= MAX_ATTEMPTS:
+        # Rediriger vers une URL spécifique après 3 tentatives échouées
+        print('Trop de tentatives (',attempts,') avec code erroné depuis :', ip_address,'. Code utilisé: ',code)
+        return redirect(REDIRECT_URL)
+    
+    flash('Code secret incorrect. Veuillez réessayer.', 'error')
+    return redirect(url_for('web_app.show_login'))
+
+@bp.route('/show_login')
+def show_login():
+    return render_template('login.html')
+
+@bp.route('/')
+def dashboard():
+    if not session.get('authenticated'):
+        return redirect(url_for('web_app.login'))  # Redirige vers la page de connexion si non authentifié
+    
+    """
+    Route pour afficher le tableau de bord des tâches planifiées.
+    """
+    tasks = ScheduledTask.query.all()
+    task_statuses = {
+        'fetch_raw_data': {'status': 'Not Executed', 'last_run': 'N/A'},
+        'construct_user_profiles': {'status': 'Not Executed', 'last_run': 'N/A'},
+        'calculate_metrics': {'status': 'Not Executed', 'last_run': 'N/A'},
+        'initiation_integration': {'status': 'Not Executed', 'last_run': 'N/A'},
+        'engagement_promotion': {'status': 'Not Executed', 'last_run': 'N/A'},
+        'quality_improvement': {'status': 'Not Executed', 'last_run': 'N/A'},
+        'urban_discovery': {'status': 'Not Executed', 'last_run': 'N/A'},
+        'interaction_reflection': {'status': 'Not Executed', 'last_run': 'N/A'},
+        'send_notifications': {'status': 'Not Executed', 'last_run': 'N/A'},
+    }
+
+    # Mise à jour avec les données réelles de la base de données
+    for task in tasks:
+        if task.action in task_statuses:
+            task_statuses[task.action]['status'] = task.status or 'Not Executed'
+            task_statuses[task.action]['last_run'] = task.last_run or 'N/A'
+
+    return render_template('dashboard.html', tasks=tasks, **task_statuses)
+
+@bp.route('/metric_details/<task_id>', methods=['GET'])
+def details(task_id):
+    # Récupérer les données en fonction de task_id
+    category = ''
+    if task_id =='calculate_adoption_metrics':
+        category = "CAT1-Adoption_Integration"
+    elif task_id =='calculate_engagement_metrics':
+        category = "CAT2-Engagement"
+    elif task_id =='calculate_quality_metrics':
+        category = "Qualité du contenu"
+    elif task_id =='calculate_urban_discovery_metrics':
+        category = "Découverte Urbaine"
+    elif task_id =='calculate_interaction_reflection_metrics':
+        category = "Interaction et réflexion"
+    print(category)
+    data_df = fetch_category_indicators(category)
+    
+    data_df = data_df[['user_id','type', 'value','date']]  
+    
+    data = data_df.to_dict(orient='records')
+
+    types = data_df['type'].unique().tolist()
+
+    users = data_df['user_id'].unique().tolist()
+
+    if not data:
+        return jsonify({'message': 'Aucun détail à afficher pour cette tâche.'}), 404
+
+    # Afficher le template avec les données
+    return render_template('action_details.html', task_id=task_id, data=data, types=types, users=users)
+    
+
+@bp.route('/recos_details/<task_id>', methods=['GET'])
+def reco_details(task_id):
+    # Récupérer les données en fonction de task_id
+    category = ''
+    if task_id =='adoption_metrics':
+        category = "Adoption et Intégration"
+    elif task_id =='engagement_metrics':
+        category = "Engagement"
+    elif task_id =='quality_metrics':
+        category = "Qualité du contenu"
+    elif task_id =='urban_discovery_metrics':
+        category = "Découverte Urbaine"
+    elif task_id =='interaction_reflection_metrics':
+        category = "Interaction et réflexion"
+
+    category = "Engagement"
+
+    data_df = fetch_category_recommendations(category)
+    print('data: ', data_df)
+    
+    data_df = data_df[['user_id','category', 'strategy', 'recommendation' ,'created_at']]  
+    
+    data = data_df.to_dict(orient='records')
+
+    types = data_df['strategy'].unique().tolist()
+
+    users = data_df['user_id'].unique().tolist()
+
+    if not data:
+        return jsonify({'message': 'Aucun détail à afficher pour cette tâche.'}), 404
+
+    # Afficher le template avec les données
+    return render_template('reco_details.html', task_id=task_id, data=data, types=types, users=users)
+
+# Ajouter une route pour la déconnexion
+@bp.route('/logout')
+def logout():
+    session.pop('authenticated', None)
+    flash('Vous avez été déconnecté.', 'info')
+    return redirect(url_for('web_app.login'))
+
+
+@bp.route('/execute_task/<string:task_action>', methods=['POST'])
+def execute_task(task_action):
+    """
+    Route pour exécuter une tâche spécifique.
+    """
+    print('Action to execute: ', task_action)
+    return execute_task_by_action(task_action)
+
+@bp.route('/execute_task_now/<int:task_id>')
+def execute_task_now(task_id):
+    """
+    Route pour exécuter une tâche planifiée immédiatement.
+    """
+    task = ScheduledTask.query.get_or_404(task_id)
+    execute_task_function(task.action)
+    task.status = 'Executed'
+    task.last_run = datetime.now(pytz.utc)
+    db.session.commit()
+    return redirect(url_for('web_app.dashboard'))
+
+@bp.route('/delete_task/<int:task_id>')
+def delete_task(task_id):
+    """
+    Route pour supprimer une tâche planifiée.
+    """
+    task = ScheduledTask.query.get_or_404(task_id)
+    db.session.delete(task)
+    db.session.commit()
+    return redirect(url_for('web_app.dashboard'))
+
+@bp.route('/edit_task/<int:task_id>', methods=['GET', 'POST'])
+def edit_task(task_id):
+    """
+    Route pour éditer une tâche planifiée.
+    """
+    task = ScheduledTask.query.get_or_404(task_id)
+    
+    if request.method == 'POST':
+        task.action = request.form['task_action']
+        task.scheduled_time = datetime.fromisoformat(request.form['scheduled_time'])
+        task.recurrence = request.form.get('recurrence', '')
+        custom_recurrence = request.form.get('custom_recurrence', '')
+        
+        task.recurrence = custom_recurrence if task.recurrence == 'custom' else task.recurrence
+        
+        db.session.commit()
+        return redirect(url_for('web_app.dashboard'))
+
+    return render_template('edit_task.html', task=task)
+
+
+@bp.route('/schedule_task', methods=['POST'])
+def schedule_task():
+    """
+    Route pour planifier ou modifier une tâche planifiée.
+    """
+    task_name = request.form.get('task_name')
+    task_action = request.form['task_action']
+    task_id = request.form.get('task_id')
+    form_mode = request.form['form_mode']
+    scheduled_time = datetime.fromisoformat(request.form['scheduled_time'])
+    creation_time = datetime.utcnow()
+    recurrence = request.form.get('recurrence', '')
+    custom_recurrence = request.form.get('custom_recurrence', '')
+
+    if form_mode == 'edit' and task_id:
+        task = ScheduledTask.query.get_or_404(task_id)
+        task.action = task_action
+        task.name = task_name
+        task.scheduled_time = scheduled_time
+        task.recurrence = recurrence if recurrence != 'custom' else custom_recurrence
+        db.session.commit()
+    elif form_mode == 'create':
+        task = ScheduledTask(
+            name=task_name,
+            action=task_action,
+            creation_time=creation_time,
+            scheduled_time=scheduled_time,
+            recurrence=recurrence if recurrence != 'custom' else custom_recurrence
+        )
+        db.session.add(task)
+        db.session.commit()
+    
+    reload_scheduled_jobs()
+    return redirect(url_for('web_app.dashboard'))
+
+@bp.route('/delete_all_scheduled_tasks', methods=['POST'])
+def delete_all_scheduled_tasks_route():
+    """
+    Route pour supprimer toutes les tâches planifiées.
+    """
+    result = delete_all_scheduled_tasks()
+    reload_scheduled_jobs()
+    # return redirect(url_for('web_app.dashboard'))
+    return jsonify(result)
+
diff --git a/rs/web_app/static/anrmobiles.png b/rs/web_app/static/anrmobiles.png
new file mode 100644
index 0000000000000000000000000000000000000000..4cf0f6d082f13a061c29f53591d872c900506e63
Binary files /dev/null and b/rs/web_app/static/anrmobiles.png differ
diff --git a/rs/web_app/static/js/metrics_charts.js b/rs/web_app/static/js/metrics_charts.js
new file mode 100644
index 0000000000000000000000000000000000000000..c87e8391e5419518c7f0d795765bb035993abb10
--- /dev/null
+++ b/rs/web_app/static/js/metrics_charts.js
@@ -0,0 +1,545 @@
+document.addEventListener('DOMContentLoaded', function() {
+    function showAlert(message) {
+        document.getElementById('alertMessage').textContent = message;
+        $('#alertModal').modal('show');
+    }
+    function loadAndInitializeModal(taskId) {
+        const container = document.getElementById('detailsModalContainer');
+
+        const modalId = 'detailsModal';
+        
+        // Supprime l'ancien modal s'il existe
+        const existingModal = container.querySelector(`#${modalId}`);
+        if (existingModal) {
+            existingModal.remove();
+        }
+
+        if (container.querySelector('#detailsModal')) {
+            $('#detailsModal').modal('show');
+            resetModal();
+            initializeModal();
+            return;
+        }
+
+        fetch(`/metric_details/${taskId}`)
+            .then(response => {
+                if (response.status === 404) {
+                    showAlert('Aucun détail à afficher pour cette tâche.');
+                    return; // Sortir de la fonction
+                }
+                return response.text();
+            })
+            .then(html => {
+                container.innerHTML = html;
+                $('#detailsModal').modal('show');
+                resetModal();
+                initializeModal();
+            })
+            .catch(error => console.error('Error loading modal content:', error));
+    }
+
+    function resetModal() {
+        const typeSelect = document.getElementById('typeSelect');
+        const chartTypeSelect = document.getElementById('chartTypeSelect');
+        const dataTableContainer = document.getElementById('dataTableContainer');
+        const chartContainer = document.getElementById('chartContainer');
+        const userSelect = document.getElementById('userSelect');
+        const timeSelect = document.getElementById('timeSelect');
+        const customDateRange = document.getElementById('customDateRange');
+        const startDate = document.getElementById('startDate');
+        const endDate = document.getElementById('endDate');
+    
+        // Réinitialiser les sélections de type de données et de type de graphique
+        if (typeSelect) {
+            typeSelect.value = '';
+        }
+    
+        if (chartTypeSelect) {
+            chartTypeSelect.style.display = 'none';
+            chartTypeSelect.value = ''; // Réinitialiser la sélection du type de graphique
+        }
+    
+        // Réinitialiser les sélections d'utilisateurs
+        if (userSelect) {
+            $(userSelect).val(null).trigger('change'); // Utilise Select2 pour réinitialiser la sélection
+        }
+    
+        // Réinitialiser la sélection de la période de temps
+        if (timeSelect) {
+            timeSelect.value = '';
+        }
+    
+        // Réinitialiser les champs de date personnalisée
+        if (customDateRange) {
+            customDateRange.classList.add('d-none'); // Masquer les champs de date
+        }
+        if (startDate) {
+            startDate.value = '';
+        }
+        if (endDate) {
+            endDate.value = '';
+        }
+    
+        // Réinitialiser l'affichage du tableau et du conteneur du graphique
+        if (dataTableContainer) {
+            dataTableContainer.style.display = 'none';
+            const dataTable = document.getElementById('dataTable');
+            if (dataTable) {
+                dataTable.style.display = 'none';
+            }
+        }
+    
+        if (chartContainer) {
+            chartContainer.style.display = 'none';
+        }
+    }
+    
+
+    function initializeModal() {
+        console.log('Modal initialized');
+
+        const typeSelect = document.getElementById('typeSelect');
+        const chartTypeSelect = document.getElementById('chartTypeSelect');
+        const userSelect = document.getElementById('userSelect');
+        const timeSelect = document.getElementById('timeSelect');
+
+        $('#userSelect').select2({
+            placeholder: "Utilisateurs",
+            allowClear: true,
+            width: '100%', // Utiliser 100% pour une largeur cohérente
+            closeOnSelect: false // Garder le sélecteur ouvert après une sélection
+        });
+
+        
+
+        if (!typeSelect || !chartTypeSelect || !userSelect || !timeSelect) {
+            console.error('Required select elements not found');
+            return;
+        }
+
+        typeSelect.addEventListener('change', handleTypeChange);
+        chartTypeSelect.addEventListener('click',  handleChartTypeChange );
+        userSelect.addEventListener('change', handleDataFilter);
+        timeSelect.addEventListener('change', handleDataFilter);
+
+        function handleDataFilter(){            
+            resetModal()
+            filterData()
+        }
+
+        // Charger les données JSON
+        const jsonDataElement = document.getElementById('jsonDataScript');
+        if (!jsonDataElement) {
+            console.error('JSON data script not found.');
+            return;
+        }
+        window.data = JSON.parse(jsonDataElement.textContent);
+
+        document.getElementById('timeSelect').addEventListener('change', function() {
+            const selectedValue = this.value;
+            const customDateRange = document.getElementById('customDateRange');
+            
+            if (selectedValue === 'custom') {
+                customDateRange.classList.remove('d-none');
+            } else {
+                customDateRange.classList.add('d-none');
+            }
+        
+            // Appeler la fonction de filtrage pour mettre à jour les données en fonction de la période sélectionnée
+            filterData();
+        });
+
+    }
+
+ 
+    function handleTypeChange(event) {
+        const selectedType = event.target.value;
+        console.log('Selected Type:', selectedType);
+        const chartTypeSelect = document.getElementById('chartTypeSelect');
+        chartTypeSelect.innerHTML = ''; // Réinitialiser les icônes de type de graphique
+
+        if (selectedType) {
+            const chartOptions = getChartOptionsForType(selectedType);
+            chartOptions.forEach(option => {
+                const iconButton = document.createElement('button');
+                iconButton.className = 'btn btn-light'; // Utilisez les classes Bootstrap pour le style
+                iconButton.innerHTML = `<i class="${option.icon}"></i>`;
+                iconButton.value = option.value;
+                iconButton.onclick = () => {
+                    // Ajoutez ici la logique pour gérer le clic sur l'icône
+                    console.log(`Selected chart type: ${option.value}`);
+                };
+                chartTypeSelect.appendChild(iconButton);
+            });
+            chartTypeSelect.style.display = ''; // Afficher le sélecteur de type de graphique
+        }
+
+
+        // Cacher le graphique et afficher le tableau
+        const dataTableContainer = document.getElementById('dataTableContainer');
+        if (dataTableContainer) {
+            dataTableContainer.style.display = 'none';
+            const dataTable = document.getElementById('dataTable');
+            if (dataTable) {
+                dataTable.style.display = 'none';
+            }
+        }
+
+        const chartContainer = document.getElementById('chartContainer');
+        if (chartContainer) {
+            chartContainer.style.display = 'none';
+        }
+
+        // Filtrer les données
+        filterData();
+    }
+
+    function getChartOptionsForType(type) {
+        return [
+            { value: 'table', label: 'Table', icon: 'bi bi-table' },
+            { value: 'bar', label: 'Barres', icon: 'bi bi-bar-chart' },
+            { value: 'line', label: 'Ligne', icon: 'bi bi-graph-up' },
+            { value: 'pie', label: 'Camembert', icon: 'bi bi-pie-chart' },
+            { value: 'doughnut', label: 'Donut', icon: 'bi bi-circle-fill' }, // Utilisation de 'circle-fill' pour le donut
+            { value: 'radar', label: 'Radar', icon: 'bi bi-broadcast-pin' }, // Utilisation de 'broadcast-pin' pour le radar
+            { value: 'polarArea', label: 'Zone Polaire', icon: 'bi bi-circle-half' }, // Utilisation de 'circle-half' pour la zone polaire
+            { value: 'bubble', label: 'Bulle', icon: 'bi bi-chat-dots' }, // Utilisation de 'chat-dots' pour la bulle
+            { value: 'scatter', label: 'Dispersion', icon: 'bi bi-grid-3x3' } // Utilisation de 'grid-3x3' pour la dispersion
+        ];
+    }
+    
+
+
+    function handleChartTypeChange(event) {
+        
+        if (event.target.tagName === 'BUTTON' || event.target.tagName === 'I') {
+                const selectedButton = event.target.tagName === 'BUTTON' ? event.target : event.target.parentElement;
+                const selectedValue = selectedButton.value;
+                console.log(`Selected chart type: ${selectedValue}`);
+                
+                const selectedChartType = selectedValue;
+                console.log('Selected Chart Type:', selectedChartType);
+
+                const chartContainer = document.getElementById('chartContainer');
+                const dataTableContainer = document.getElementById('dataTableContainer');
+                const typeSelect = document.getElementById('typeSelect').value;
+
+                if (selectedChartType === 'table') {
+                    if (chartContainer) {
+                        chartContainer.style.display = 'none';
+                    }
+                    if (dataTableContainer) {
+                        dataTableContainer.style.display = '';
+                        const dataTable = document.getElementById('dataTable');
+                        if (dataTable) {
+                            dataTable.style.display = '';
+                        }
+                        updateTable(); // Mettre à jour le tableau avec les données
+                    }
+                } else if (selectedChartType) {
+                    if (chartContainer) {
+                        chartContainer.style.display = '';
+                    }
+                    if (dataTableContainer) {
+                        dataTableContainer.style.display = 'none';
+                        const dataTable = document.getElementById('dataTable');
+                        if (dataTable) {
+                            dataTable.style.display = 'none';
+                        }
+                    }
+                    updateChart(typeSelect, selectedChartType);
+                } else {
+                    if (chartContainer) {
+                        chartContainer.style.display = 'none';
+                    }
+                    if (dataTableContainer) {
+                        dataTableContainer.style.display = 'none';
+                    }
+                }
+        }
+        
+        
+    }
+
+    function updateChart(type, chartType) {
+        const canvas = document.getElementById('metricsChart');
+        const ctx = canvas.getContext('2d');
+    
+        // Détruire le graphique existant, si nécessaire
+        if (window.metricsChart && window.metricsChart instanceof Chart) {
+            window.metricsChart.destroy(); // Détruire l'ancien graphique
+        }
+    
+        // Réinitialiser le canvas pour éviter des problèmes de contexte
+        canvas.width = canvas.width; // Réinitialiser le canvas
+    
+        const data = filterData(); // Utiliser les données filtrées
+    
+        if (data.length === 0) {
+            console.log('No data available for chart.');
+            return;
+        }
+    
+        const formattedData = formatDataForChart(data, chartType);
+    
+        window.metricsChart = new Chart(ctx, {
+            type: chartType,
+            data: {
+                labels: formattedData.labels,
+                datasets: [{
+                    label: 'Dataset',
+                    data: formattedData.values,
+                    backgroundColor: getChartColors(chartType, formattedData.labels.length),
+                    borderColor: getChartColors(chartType, formattedData.labels.length, true),
+                    borderWidth: 1
+                }]
+            },
+            options: {
+                scales: {
+                    y: {
+                        beginAtZero: true
+                    }
+                }
+            }
+        });
+    }
+    
+
+    function formatDataForChart(data, chartType) {
+        // Regrouper les données par type
+        const groupedData = groupDataByValue(data);
+    
+        // Formater les données selon le type de graphique
+        switch (chartType) {
+            case 'bar':
+            case 'horizontalBar':
+                return formatBarData(groupedData);
+            case 'line':
+                return formatLineData(groupedData);
+            case 'pie':
+            case 'doughnut':
+                return formatPieDoughnutData(groupedData);
+            case 'radar':
+                return formatRadarData(groupedData);
+            case 'polarArea':
+                return formatPolarAreaData(groupedData);
+            case 'bubble':
+                return formatBubbleData(groupedData);
+            case 'scatter':
+                return formatScatterData(groupedData);
+            default:
+                console.error(`Unsupported chart type: ${chartType}`);
+                return { labels: [], values: [] };
+        }
+    }
+    
+    function groupDataByValue(data) {
+        return data.reduce((acc, item) => {
+            if (!acc[item.value]) {
+                acc[item.value] = 0;
+            }
+            acc[item.value] += 1;
+            return acc;
+        }, {});
+    }
+    
+    function formatBarData(groupedData) {
+        const labels = Object.keys(groupedData);
+        const values = labels.map(key => groupedData[key]);
+    
+        return {
+            labels,
+            values
+        };
+    }
+    
+    function formatLineData(groupedData) {
+        const labels = Object.keys(groupedData);
+        const values = labels.map(key => groupedData[key]);
+    
+        return {
+            labels,
+            values: values.map(value => ({ x: labels.indexOf(value.toString()) + 1, y: value }))
+        };
+    }
+    
+    function formatPieDoughnutData(groupedData) {
+        const labels = Object.keys(groupedData);
+        const values = labels.map(key => groupedData[key]);
+    
+        return {
+            labels,
+            values
+        };
+    }
+    
+    function formatRadarData(groupedData) {
+        const labels = Object.keys(groupedData);
+        const values = labels.map(key => groupedData[key]);
+    
+        return {
+            labels,
+            values
+        };
+    }
+    
+    function formatPolarAreaData(groupedData) {
+        const labels = Object.keys(groupedData);
+        const values = labels.map(key => groupedData[key]);
+    
+        return {
+            labels,
+            values
+        };
+    }
+    
+    function formatBubbleData(groupedData) {
+        // Format pour les bulles : x, y, r
+        const labels = Object.keys(groupedData);
+        const values = labels.map((key, index) => ({
+            x: index + 1, // Position en x
+            y: groupedData[key],
+            r: groupedData[key] // Taille des bulles
+        }));
+    
+        return {
+            labels,
+            values
+        };
+    }
+    
+    function formatScatterData(groupedData) {
+        // Format pour les graphiques de dispersion : x, y
+        const labels = Object.keys(groupedData);
+        const values = labels.map((key, index) => ({
+            x: index + 1, // Position en x
+            y: groupedData[key]
+        }));
+    
+        return {
+            labels,
+            values
+        };
+    }
+    
+
+    function getChartColors(chartType, count, isBorder = false) {
+        const colors = {
+            bar: 'rgba(75, 192, 192, 0.2)',
+            horizontalBar: 'rgba(75, 192, 192, 0.2)',
+            line: 'rgba(75, 192, 192, 0.2)',
+            pie: 'rgba(75, 192, 192, 0.2)',
+            doughnut: 'rgba(75, 192, 192, 0.2)',
+            radar: 'rgba(75, 192, 192, 0.2)',
+            polarArea: 'rgba(75, 192, 192, 0.2)',
+            bubble: 'rgba(75, 192, 192, 0.2)',
+            scatter: 'rgba(75, 192, 192, 0.2)',
+            area: 'rgba(75, 192, 192, 0.2)'
+        };
+        const borderColor = isBorder ? 'rgba(75, 192, 192, 1)' : colors[chartType] || 'rgba(0,0,0,0.1)';
+        return Array(count).fill(borderColor);
+    }
+
+    function updateTable() {
+        const table = document.getElementById('dataTable');
+        const data = filterData(document.getElementById('typeSelect').value);
+        const rows = data.map(item => `<tr><td>${item.type}</td><td>${item.value}</td></tr>`).join('');
+        table.innerHTML = `<table class="table table-striped"><thead><tr><th>Type</th><th>Value</th></tr></thead><tbody>${rows}</tbody></table>`;
+    }
+
+    function filterData() {
+        // Récupérer la sélection multiple des utilisateurs
+        const userSelect = document.getElementById('userSelect');
+        const userFilter = $(userSelect).val() || []; // Utilise jQuery pour obtenir les valeurs sélectionnées
+        const timeSelect = document.getElementById('timeSelect').value || null;
+        const type = document.getElementById('typeSelect').value || null;
+    
+        console.log('Filtering data based on DOM values.');
+    
+        const jsonDataElement = document.getElementById('jsonDataScript');
+        if (!jsonDataElement) {
+            console.error('JSON data script not found.');
+            return [];
+        }
+        const jsonData = JSON.parse(jsonDataElement.textContent);
+    
+        // Fonction utilitaire pour vérifier si une valeur est considérée comme non fournie
+        function isNotProvided(value) {
+            return value === null || value === undefined || value === '';
+        }
+    
+        // Fonction pour obtenir la date de début et de fin en fonction de la sélection de période
+        function getDateRange(selection) {
+            const now = new Date();
+            let start = new Date();
+            let end = new Date();
+    
+            switch (selection) {
+                case 'today':
+                    start = end = now;
+                    break;
+                case 'this_week':
+                    start.setDate(now.getDate() - now.getDay());
+                    end.setDate(start.getDate() + 6);
+                    break;
+                case 'this_month':
+                    start.setDate(1);
+                    end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+                    break;
+                case 'this_year':
+                    start = new Date(now.getFullYear(), 0, 1);
+                    end = new Date(now.getFullYear(), 11, 31);
+                    break;
+                case 'custom':
+                    start = new Date(document.getElementById('startDate').value);
+                    end = new Date(document.getElementById('endDate').value);
+                    break;
+                default:
+                    return { start: null, end: null };
+            }
+            return { start, end };
+        }
+    
+        const { start, end } = getDateRange(timeSelect);
+    
+        // Filtrer les données en fonction des filtres
+        const filteredData = jsonData.filter(item => {
+            let match = true;
+            if (!isNotProvided(type)) {
+                match = match && item.type === type;
+            }
+            if (userFilter.length > 0) {
+                if (userFilter.includes('all')) {
+                    match = match && true; // Sélectionne tous les utilisateurs
+                } else {
+                    match = match && userFilter.includes(String(item.user_id));
+                }
+            }
+            if (start && end) {
+                const itemDate = new Date(item.date);
+                console.log('Item date: ', itemDate, ' - start: ', start, ' - end: ', end)
+                match = match && itemDate >= start && itemDate <= end;
+            }
+    
+            return match;
+        });
+    
+        if (filteredData.length === 0) {
+            console.log('No data available for filtering.');
+        } else {
+            console.log('Filtered Data:', filteredData);
+        }
+        
+    
+        return filteredData;
+    }
+    
+    
+
+    document.querySelectorAll('.metrics-details').forEach(button => {
+        button.addEventListener('click', function() {
+            const taskId = this.dataset.task;
+            loadAndInitializeModal(taskId);
+        });
+    });
+});
diff --git a/rs/web_app/static/js/recos_charts.js b/rs/web_app/static/js/recos_charts.js
new file mode 100644
index 0000000000000000000000000000000000000000..7fa1bfae759c2d7ac75233de67cda0144a5bee49
--- /dev/null
+++ b/rs/web_app/static/js/recos_charts.js
@@ -0,0 +1,303 @@
+document.addEventListener('DOMContentLoaded', function() {
+    function showAlert(message) {
+        document.getElementById('alertMessage').textContent = message;
+        $('#alertModal').modal('show');
+    }
+    function loadAndInitializeModal(taskId) {
+        const container = document.getElementById('recosDetailsModalContainer');
+
+        const modalId = 'recosDetailsModal';
+        
+        // Supprime l'ancien modal s'il existe
+        const existingModal = container.querySelector(`#${modalId}`);
+        if (existingModal) {
+            existingModal.remove();
+        }
+
+        if (container.querySelector('#recosDetailsModal')) {
+            $('#recosDetailsModal').modal('show');
+            resetModal();
+            initializeModal();
+            return;
+        }
+
+        fetch(`/recos_details/${taskId}`)
+            .then(response => {
+                if (response.status === 404) {
+                    showAlert('Aucun détail à afficher pour cette tâche.');
+                    return; // Sortir de la fonction
+                }
+                return response.text();
+            })
+            .then(html => {
+                container.innerHTML = html;
+                $('#recosDetailsModal').modal('show');
+                resetModal();
+                initializeModal();
+            })
+            .catch(error => console.error('Error loading modal content:', error));
+    }
+
+    function resetModal() {
+        const recoTypeSelect = document.getElementById('recoTypeSelect');
+        const recoDataTableContainer = document.getElementById('recoDataTableContainer');
+        const recoUserSelect = document.getElementById('recoUserSelect');
+        const recoTimeSelect = document.getElementById('recoTimeSelect');
+        const customDateRange = document.getElementById('customDateRange');
+        const startDate = document.getElementById('startDate');
+        const endDate = document.getElementById('endDate');
+    
+        // Réinitialiser les sélections de type de données et de type de graphique
+        if (recoTypeSelect) {
+            recoTypeSelect.value = '';
+        }
+    
+       
+    
+        // Réinitialiser les sélections d'utilisateurs
+        if (recoUserSelect) {
+            $(recoUserSelect).val(null).trigger('change'); // Utilise Select2 pour réinitialiser la sélection
+        }
+    
+        // Réinitialiser la sélection de la période de temps
+        if (recoTimeSelect) {
+            recoTimeSelect.value = '';
+        }
+    
+        // Réinitialiser les champs de date personnalisée
+        if (customDateRange) {
+            customDateRange.classList.add('d-none'); // Masquer les champs de date
+        }
+        if (startDate) {
+            startDate.value = '';
+        }
+        if (endDate) {
+            endDate.value = '';
+        }
+    
+        // Réinitialiser l'affichage du tableau et du conteneur du graphique
+        if (recoDataTableContainer) {
+            recoDataTableContainer.style.display = 'none';
+            const dataTable = document.getElementById('recoDataTable');
+            if (dataTable) {
+                dataTable.style.display = 'none';
+            }
+        }
+    
+        
+    }
+    
+
+    function initializeModal() {
+        console.log('Modal initialized');
+
+        const recoTypeSelect = document.getElementById('recoTypeSelect');
+        const recoTimeSelect = document.getElementById('recoTimeSelect');
+
+        $('#recoUserSelect').select2({
+            placeholder: "Utilisateurs",
+            allowClear: true,
+            width: '100%', // Utiliser 100% pour une largeur cohérente
+            closeOnSelect: false // Garder le sélecteur ouvert après une sélection
+        })
+        .on('change', updateTable);;
+
+        
+        
+
+        
+
+        if (!recoTypeSelect ||   !recoTimeSelect) {
+            console.error('Required select elements not found');
+            return;
+        }
+
+        recoTypeSelect.addEventListener('change', updateTable);
+        recoTimeSelect.addEventListener('change', updateTable);
+
+        function handleDataFilter(){            
+            console.log('user change')
+        }
+
+        // Charger les données JSON
+        const jsonDataElement = document.getElementById('jsonDataScript');
+        if (!jsonDataElement) {
+            console.error('JSON data script not found.');
+            return;
+        }
+        window.data = JSON.parse(jsonDataElement.textContent);
+
+        document.getElementById('recoTimeSelect').addEventListener('change', function() {
+            const selectedValue = this.value;
+            const customDateRange = document.getElementById('customDateRange');
+            
+            if (selectedValue === 'custom') {
+                customDateRange.classList.remove('d-none');
+            } else {
+                customDateRange.classList.add('d-none');
+            }
+        
+            // Appeler la fonction de filtrage pour mettre à jour les données en fonction de la période sélectionnée
+            filterRecoData();
+        });
+
+    }
+
+ 
+    function handleTypeChange(event) {
+        const selectedType = event.target.value;
+        const recoDataTableContainer = document.getElementById('recoDataTableContainer');
+        if (recoDataTableContainer) {
+            recoDataTableContainer.style.display = 'none';
+            const dataTable = document.getElementById('recoDataTable');
+            if (dataTable) {
+                dataTable.style.display = 'none';
+            }
+        }
+
+        const chartContainer = document.getElementById('chartContainer');
+        if (chartContainer) {
+            chartContainer.style.display = 'none';
+        }
+
+        // Filtrer les données
+        updateTable();
+    }
+
+
+    
+    function updateTable() {
+        const table = document.getElementById('recoDataTable');
+        const data = filterRecoData();
+    
+        // Générer les lignes du tableau en fonction des champs des données
+        const rows = data.map(item => `
+            <tr>
+                <td>${item.strategy || 'N/A'}</td>
+                <td>${item.category || 'N/A'}</td>
+                <td>${item.user_id || 'N/A'}</td>
+                <td>${new Date(item.created_at).toLocaleString() || 'N/A'}</td>
+                <td>${item.recommendation || 'N/A'}</td>
+            </tr>
+        `).join('');
+    
+        // Mettre à jour le contenu du tableau avec les nouvelles données
+        table.innerHTML = `
+            <table class="table table-striped">
+                <thead>
+                    <tr>
+                        <th>Stratégie</th>
+                        <th>Catégorie</th>
+                        <th>ID Utilisateur</th>
+                        <th>Date de Création</th>
+                        <th>Recommandation</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    ${rows}
+                </tbody>
+            </table>
+        `;
+    }
+    
+
+    function filterRecoData() {
+        // Récupérer la sélection multiple des utilisateurs
+        const recoUserSelect = document.getElementById('recoUserSelect');
+        const userFilter = $(recoUserSelect).val() || []; // Utilise jQuery pour obtenir les valeurs sélectionnées
+        const recoTimeSelect = document.getElementById('recoTimeSelect').value || null;
+        const type = document.getElementById('recoTypeSelect').value || null;
+    
+       
+        const jsonDataElement = document.getElementById('jsonRecoDataScript');
+        if (!jsonDataElement) {
+            console.error('JSON data script not found.');
+            return [];
+        }
+        const jsonData = JSON.parse(jsonDataElement.textContent);
+    
+        // Fonction utilitaire pour vérifier si une valeur est considérée comme non fournie
+        function isNotProvided(value) {
+            return value === null || value === undefined || value === '';
+        }
+    
+        // Fonction pour obtenir la date de début et de fin en fonction de la sélection de période
+        function getDateRange(selection) {
+            const now = new Date();
+            let start = new Date();
+            let end = new Date();
+    
+            switch (selection) {
+                case 'today':
+                    start = end = now;
+                    break;
+                case 'this_week':
+                    start.setDate(now.getDate() - now.getDay());
+                    end.setDate(start.getDate() + 6);
+                    break;
+                case 'this_month':
+                    start.setDate(1);
+                    end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+                    break;
+                case 'this_year':
+                    start = new Date(now.getFullYear(), 0, 1);
+                    end = new Date(now.getFullYear(), 11, 31);
+                    break;
+                case 'custom':
+                    start = new Date(document.getElementById('startDate').value);
+                    end = new Date(document.getElementById('endDate').value);
+                    break;
+                default:
+                    return { start: null, end: null };
+            }
+            return { start, end };
+        }
+    
+        const { start, end } = getDateRange(recoTimeSelect);
+    
+        // Filtrer les données en fonction des filtres
+        const filteredData = jsonData.filter(item => {
+            let match = true;
+            if (!isNotProvided(type)) {
+                match = match && item.strategy === type;
+            }
+            if (userFilter.length > 0) {
+                if (userFilter.includes('all')) {
+                    match = match && true; // Sélectionne tous les utilisateurs
+                } else {
+                    match = match && userFilter.includes(String(item.user_id));
+                }
+            }
+            if (start && end) {
+                const itemDate = new Date(item.created_at);
+                match = match && itemDate >= start && itemDate <= end;
+            }
+
+    
+            return match;
+        });
+    
+        if (filteredData.length === 0) {
+            console.log('No data available for filtering.');
+        }
+
+        const recoDataTableContainer = document.getElementById('recoDataTableContainer');
+        recoDataTableContainer.style.display = ''
+        const dataTable = document.getElementById('recoDataTable');
+        if (dataTable) {
+            dataTable.style.display = '';
+        }
+        
+    
+        return filteredData;
+    }
+    
+    
+
+    document.querySelectorAll('.recos-details').forEach(button => {
+        button.addEventListener('click', function() {
+            const taskId = this.dataset.task;
+            loadAndInitializeModal(taskId);
+        });
+    });
+});
diff --git a/rs/web_app/static/styles.css b/rs/web_app/static/styles.css
new file mode 100644
index 0000000000000000000000000000000000000000..7e83ed210c1763ee6a54b9807fe6a9a040ce592c
--- /dev/null
+++ b/rs/web_app/static/styles.css
@@ -0,0 +1,499 @@
+/* Global Styles */
+body {
+    margin: 0;
+    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+    background-color: #f0f2f5;
+    color: #333;
+    line-height: 1.6;
+    overflow-x: hidden;
+}
+
+a {
+    color: inherit;
+    text-decoration: none;
+    transition: color 0.3s ease;
+}
+
+a:hover {
+    color: #4e73df;
+}
+
+/* Utility Classes */
+.hidden {
+    display: none;
+}
+
+.flex-center {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
+.text-center {
+    text-align: center;
+}
+
+.margin-bottom-lg {
+    margin-bottom: 40px;
+}
+
+/* Header Styles */
+.header {
+    background-color: #4e73df;
+    color: #ffffff;
+    padding: 15px 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+    position: sticky;
+    top: 0;
+    z-index: 100;
+}
+
+.header img {
+    height: 50px;
+    margin-right: 15px;
+}
+
+.header h1 {
+    font-size: 2.2rem;
+    font-weight: 700;
+    margin: 0;
+    letter-spacing: 1px;
+}
+
+/* Content Container */
+.content {
+    max-width: 1200px;
+    margin: 60px auto;
+    padding: 30px;
+    background-color: #ffffff;
+    border-radius: 12px;
+    box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
+    animation: fadeIn 0.5s ease-in;
+}
+
+/* Fade-In Animation */
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(10px);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+/* Button Styles */
+.btn {
+    font-size: 1rem;
+    padding: 10px 25px;
+    display: inline-block;
+    transition: all 0.3s ease;
+    text-align: center;
+    cursor: pointer;
+    border: 2px solid #ffffff;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+    letter-spacing: 0.5px;
+}
+
+.btn-primary {
+    background-color: #4e73df;
+}
+
+.btn-primary:hover {
+    background-color: #3a5fcb;
+    transform: scale(1.05);
+    box-shadow: 0 1px 12px rgba(0, 0, 0, 0.1);
+}
+
+.btn-warning {
+    background-color: #f0ad4e;
+}
+
+.btn-warning:hover {
+    background-color: #ec971f;
+    transform: scale(1.05);
+    box-shadow: 0 8px 12px rgba(0, 0, 0, 0.1);
+}
+
+.btn-secondary {
+    background-color: #6c757d;
+}
+
+.btn-secondary:hover {
+    background-color: #5a6268;
+    transform: scale(1.05);
+    box-shadow: 0 8px 12px rgba(0, 0, 0, 0.1);
+}
+
+.btn-danger {
+    background-color: #dc3545;
+}
+
+.btn-danger:hover {
+    background-color: #c82333;
+    transform: scale(1.05);
+    box-shadow: 0 8px 12px rgba(0, 0, 0, 0.1);
+}
+
+/* Task Card Styles */
+.task-card {
+    padding: 20px;
+    margin-bottom: 20px;
+    background-color: #ffffff;
+    border: 1px solid #ddd;
+    border-radius: 8px;
+    position: relative;
+    overflow: hidden;
+    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+    transition: box-shadow 0.3s;
+}
+
+.task-card::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    border: 2px solid transparent;
+    border-radius: 8px;
+    transition: border-color 0.3s;
+    pointer-events: none; /* Assure que le pseudo-élément ne bloque pas les clics */
+}
+
+/* Effet au survol */
+.task-card:hover::before {
+    border-color: #36b7f894; /* Couleur de la bordure au survol */
+    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
+}
+
+.task-icon {
+    font-size: 26px;
+    color: #4e73df;
+    margin-right: 10px;
+}
+
+.task-title {
+    font-size: 1.3rem;
+    font-weight: 600;
+    margin-bottom: 10px;
+}
+
+.task-stats {
+    font-size: 0.85rem;
+    color: #6c757d;
+    margin-bottom: 10px;
+}
+
+/* Status Styles */
+.status {
+    font-weight: bold;
+    border-radius: 3px;
+    padding: 3px 8px;
+}
+
+.status.in-progress {
+    background-color: #ffc107;
+    color: #ffffff;
+}
+
+.status.success {
+    background-color: #28a745;
+    color: #ffffff;
+}
+
+.status.failed {
+    background-color: #dc3545;
+    color: #ffffff;
+}
+
+/* Sidebar Styles */
+.sidebar {
+    height: 100%;
+    width: 0;
+    position: fixed;
+    z-index: 1000;
+    top: 0;
+    right: 0;
+    background: linear-gradient(135deg, #343a40, #1f252a);
+    overflow-x: hidden;
+    transition: width 0.4s ease;
+    padding-top: 60px;
+    box-shadow: -2px 0 10px rgba(0,0,0,0.3);
+    color: #ffffff;
+}
+
+.sidebar a {
+    padding: 15px 25px;
+    font-size: 18px;
+    color: #e0e0e0;
+    display: block;
+    transition: background-color 0.3s, padding-left 0.3s;
+}
+
+.sidebar a:hover {
+    background-color: #495057;
+    padding-left: 30px;
+}
+
+.sidebar .closebtn {
+    position: absolute;
+    top: 15px;
+    right: 25px;
+    font-size: 36px;
+    cursor: pointer;
+    color: #ffffff;
+}
+
+/* Task List Styles */
+.task-list {
+    margin-top: 20px;
+}
+
+.task-list .task-item {
+    margin-bottom: 10px;
+    padding: 15px;
+    border: 1px solid #6c757d;
+    
+    background-color: #3a3f44;
+    color: #ffffff;
+    
+    transition: transform 0.3s, box-shadow 0.3s;
+}
+
+.task-list .task-item:hover {
+    transform: translateY(-3px);
+    box-shadow: 0 4px 8px rgba(0,0,0,0.3);
+}
+
+.task-item h5 {
+    font-size: 1.25rem; /* Taille de police modérée */
+    font-weight: 600; /* Poids de police plus lourd pour le titre */
+    margin: 0 0 10px; /* Espacement inférieur */
+    
+}
+
+/* Styles pour les détails de la tâche */
+.task-item p {
+    font-size: 0.875rem; /* Taille de police plus petite pour les détails */
+   
+    margin: 0 0 15px; /* Espacement inférieur */
+}
+
+/* Category Section Styles */
+.category-section {
+    margin-bottom: 40px;
+    padding: 30px;
+    border-radius: 12px;
+    background-color: #ffffff;
+    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
+    transition: all 0.3s ease;
+}
+
+.category-section:hover {
+    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); /* Ombre légèrement plus intense */
+    background-color: #f8fafb; /* Changement subtil de la couleur de fond */
+    border-color: #d1d8e0; /* Changement de couleur de bordure pour un contraste amélioré */
+    transition: box-shadow 0.3s ease, background-color 0.3s ease, border-color 0.3s ease; /* Transitions pour une animation fluide */
+}
+
+
+.category-header {
+    font-size: 1.4rem;
+    color: #4e73df;
+    font-weight: 500;
+    border-bottom: 2px solid #e3e6f0;
+    /* padding-bottom: 5px; */
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+/* Updated Category Title Styles */
+.category-title {
+    font-size: 1.5rem; /* Slightly larger font size */
+    color: #4e73df; /* Primary color for consistency */
+    font-weight: 500; /* Bold text to make it stand out */
+    margin-bottom: 15px; /* Space below the title */
+    border-left: 4px solid #4e73df; /* Add a left border to highlight the title */
+    padding-left: 15px; /* Space between the border and text */
+    text-transform: uppercase; /* Transform text to uppercase for emphasis */
+    letter-spacing: 1px; /* Slight letter spacing for modern look */
+    padding: 10px; /* Padding around the text */
+    border-radius: 4px; /* Rounded corners for a softer look */
+    display: inline-block; /* Ensures the title fits neatly in its space */
+}
+
+
+
+/* Updated Category Actions Styles */
+.category-actions {
+    display: flex;
+    gap: 15px;
+    margin-bottom: 20px;
+    background-color: #f8f9fa; /* Light gray background for contrast */
+    padding: 15px;
+    border-radius: 8px;
+    border: 1px solid #e0e0e0;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+    justify-content: flex-start;
+    align-items: center;
+}
+
+
+.category-actions .btn {
+    margin-right: 5px;
+}
+
+/* Full Process Section Styles */
+.full-process-section {
+    background-color: #f1f1f1;
+    border-bottom: 3px solid #4e73df;
+    padding: 40px 0;
+    margin-bottom: 40px;
+}
+
+.full-process-header {
+    text-align: center;
+    max-width: 900px;
+    margin: 0 auto;
+    padding: 30px;
+    border-radius: 12px;
+    background-color: #ffffff;
+    box-shadow: 0 6px 12px rgba(0, 0, 0, 0.05);
+}
+
+.full-process-header h2 {
+    color: #4e73df;
+    font-size: 1.75rem;
+    font-weight: 600;
+    margin-bottom: 15px;
+}
+
+.full-process-header p {
+    color: #6c757d;
+    font-size: 1rem;
+    margin-bottom: 20px;
+}
+
+.full-process-icon {
+    font-size: 30px;
+    color: #dc3545;
+    margin-bottom: 15px;
+}
+
+/* Loading Indicator Styles */
+#loadingIndicator {
+    position: fixed;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    text-align: center;
+    z-index: 1000;
+}
+
+.spinner-border {
+    width: 3rem;
+    height: 3rem;
+    border-width: .3em;
+}
+
+/* Task Manager Button Styles */
+#taskManagerButton {
+    position: fixed;
+    top: 20px;
+    right: 20px;
+    font-size: 20px;
+    background-color: #2a90a1;
+    color: white;
+    border-radius: 50%;
+    padding: 10px 15px;
+    border: none;
+    cursor: pointer;
+    z-index: 1100;
+    box-shadow: 0 4px 10px #21558c;
+    transition: background-color 0.3s, transform 0.3s ease;
+}
+
+#taskManagerButton:hover {
+    background-color: #1d6e6f;
+    transform: scale(1.1);
+}
+
+/* Styles de la classe task-action */
+.task-action {
+    display: flex; /* Affiche les éléments en ligne */
+    gap: 10px; /* Espacement entre les boutons */
+    justify-content: flex-end; /* Aligne les boutons à droite */
+    margin-top: 10px; /* Espace en haut de la section */
+}
+
+/* Styles pour les boutons dans .task-action */
+.task-action a.btn {
+    font-size: 0.85rem; /* Taille de police plus petite */
+    padding: 6px 12px; /* Taille de bouton plus petite */
+    border-radius: 4px; /* Bords arrondis subtils */
+    border: 1px solid #ddd; /* Bordure discrète */
+    background-color: #f5f5f5; /* Couleur de fond discrète */
+    color: #333; /* Couleur du texte discrète */
+    text-decoration: none; /* Supprime le soulignement des liens */
+    cursor: pointer;
+    transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; /* Transitions fluides */
+}
+
+/* Effets de survol pour les boutons dans .task-action */
+.task-action a.btn:hover {
+    background-color: #c5c8cb; /* Couleur de fond légèrement plus foncée au survol */
+    border-color: #ccc; /* Changement de couleur de bordure au survol */
+    color: #222; /* Couleur du texte légèrement plus foncée au survol */
+}
+
+
+
+/* Container */
+.container {
+    max-width: 1200px;
+    margin: 0 auto;
+    padding: 20px;
+}
+
+.select2-container--default .select2-selection--multiple {
+    background-color: #fff;
+    border: 1px solid #ced4da;
+    border-radius: .25rem;
+    height: calc(2.25rem + 2px); /* Ajuste la hauteur pour correspondre aux autres champs de formulaire */
+    display: flex;
+    align-items: center;
+}
+
+.select2-container--default .select2-selection--multiple .select2-selection__choice {
+    background-color: #007bff;
+    color: white;
+    border: none;
+    border-radius: .2rem;
+    padding: 2px 5px;
+    margin-right: 2px;
+}
+
+.select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
+    color: white;
+    margin-right: 5px!important;
+}
+
+.select2-container .select2-search--inline .select2-search__field {
+    width: auto !important;
+    font-size: 0.875rem;
+}
+
+.select2-selection__rendered{
+    margin-top: auto !important;
+}
+
+.select2-container--default .select2-selection--multiple .select2-selection__choice__display {
+    padding-left: 20px;
+    padding-right: 10px;
+}
\ No newline at end of file
diff --git a/rs/web_app/tasks.py b/rs/web_app/tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..fbc5fcf11f84c20febbe46e069ed5bdcf0066348
--- /dev/null
+++ b/rs/web_app/tasks.py
@@ -0,0 +1,247 @@
+import logging
+from datetime import datetime
+import pytz
+
+from flask import current_app
+from apscheduler.schedulers.background import BackgroundScheduler
+from apscheduler.triggers.date import DateTrigger
+from apscheduler.triggers.interval import IntervalTrigger
+from apscheduler.triggers.cron import CronTrigger
+
+from modules.recommendations.adoption_integration_rec import generate_adoption_integration_recommendations
+from modules.recommendations.content_quality_rec import generate_content_quality_recommendations
+from modules.recommendations.engagement_reengagement_rec import generate_all_engagement_recommendations
+from modules.recommendations.interaction_reflection_rec import generate_all_reflection_recommendations
+from modules.recommendations.urban_discovery_rec import generate_all_urban_recommendations
+from web_app import db, scheduler
+from web_app.models import ScheduledTask, ExecutionLog
+
+# Configuration du logger
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
+handler = logging.StreamHandler()
+handler.setLevel(logging.INFO)
+formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+handler.setFormatter(formatter)
+logger.addHandler(handler)
+
+def create_app_context():
+    """Crée et retourne un contexte d'application Flask."""
+    from web_app import create_app
+    app = create_app()
+    return app.app_context()
+
+def get_or_create_log(action_type):
+    """
+    Récupère un enregistrement de journalisation existant pour l'action spécifiée, ou en crée un nouveau.
+    """
+    log = ExecutionLog.query.filter_by(action_type=action_type, status='in_progress').first()
+    if not log:
+        log = ExecutionLog(action_type=action_type, status='in_progress')
+        db.session.add(log)
+        db.session.commit()
+    return log
+
+def update_log(log, status, details=None):
+    """
+    Met à jour l'enregistrement de journalisation avec le statut et les détails spécifiés.
+    """
+    log.status = status
+    log.details = details
+    log.end_time = datetime.utcnow()
+    db.session.commit()
+
+def execute_task_function(task_id):
+    """
+    Fonction exécutée par le planificateur. 
+    Effectue l'action définie pour la tâche spécifiée par `task_id`.
+    """
+    with create_app_context() as app_context:
+        task = ScheduledTask.query.get(task_id)
+        if not task:
+            logger.warning(f"Tâche {task_id} non trouvée.")
+            return
+
+        action_type = task.action
+        log = get_or_create_log(action_type)
+
+        try:
+            # Exécute l'action associée à la tâche
+            _execute_task_action(task)
+            
+            # Mise à jour du statut de la tâche
+            task.status = 'Executed'
+            task.last_run = datetime.now(pytz.utc)
+            db.session.commit()
+            update_log(log, status='success')
+            logger.info(f"Tâche {task_id} exécutée avec succès.")
+        
+        except Exception as e:
+            task.status = 'Failed'
+            task.last_run = datetime.now(pytz.utc)
+            db.session.commit()
+            update_log(log, status='failed', details=str(e))
+            logger.error(f"Erreur lors de l'exécution de la tâche {task_id}: {e}")
+
+def _execute_task_action(task):
+    """
+    Exécute l'action spécifique associée à une tâche planifiée.
+    """
+    if task.action == 'fetch_raw_data':
+        _execute_fetch_raw_data()
+    elif task.action == 'calculate_metrics':
+        _execute_calculate_metrics()
+    elif task.action == 'construct_user_profiles':
+        _execute_construct_user_profiles()
+    # Cas pour chaque catégorie spécifique
+    elif task.action == 'calculate_adoption_metrics':
+        _execute_calculate_metrics(category='adoption')
+    elif task.action == 'calculate_engagement_metrics':
+        _execute_calculate_metrics(category='engagement')
+    elif task.action == 'calculate_quality_metrics':
+        _execute_calculate_metrics(category='quality')
+    elif task.action == 'calculate_urban_discovery_metrics':
+        _execute_calculate_metrics(category='urbain')
+    elif task.action == 'calculate_interaction_reflection_metrics':
+        _execute_calculate_metrics(category='reflexion') 
+    elif task.action == 'generate_recommendations':
+        generate_adoption_integration_recommendations()
+        generate_all_engagement_recommendations()
+        generate_content_quality_recommendations()
+        generate_all_urban_recommendations()
+        generate_all_reflection_recommendations()
+    elif task.action == 'initiation_integration':
+        _execute_initiation_integration_rec()
+    elif task.action == 'engagement_promotion':
+        _execute_engagement_promotion_rec()
+    elif task.action == 'quality_improvement':
+        _execute_quality_improvement_rec()
+    elif task.action == 'urban_discovery':
+        _execute_urban_discovery_rec()
+    elif task.action == 'interaction_reflection':
+        _execute_interaction_reflection_rec()
+    elif task.action == 'send_notifications':
+        _execute_send_notifications()
+    elif task.action == 'full_process':
+        _execute_full_process()
+    else:
+        logger.warning(f"Action non reconnue pour la tâche {task.id}: {task.action}")
+
+# Implémentation des différentes actions
+def _execute_fetch_raw_data():
+    logger.info("Début de l'exécution de la tâche: Fetch and Preprocess Data.")
+    from modules.db_operations import get_distant_users, get_distant_annotations
+    from modules.preprocessing.preprocess_annotations import preprocess_annotations
+    from modules.db_operations import save_users_to_local_database, save_annotations_to_local_database
+
+    users = get_distant_users()
+    save_users_to_local_database(users)
+    annotations = get_distant_annotations()
+    preprocess_annotations(annotations)
+    save_annotations_to_local_database(annotations)
+
+
+def _execute_calculate_metrics(category=None):
+    logger.info("Début de l'exécution de la tâche: Calculate Metrics.")
+    from modules.compute_metrics import calculate_metrics
+    calculate_metrics()
+
+def _execute_construct_user_profiles():
+    logger.info("Début de l'exécution de la tâche: Construction des profiles.")
+    from modules.db_operations import get_distant_users, get_distant_annotations
+    from modules.preprocessing.preprocess_annotations import preprocess_annotations
+    from modules.db_operations import save_users_to_local_database, save_annotations_to_local_database
+
+def _execute_generate_all_recommendendations():
+    logger.info("Début de l'exécution de la tâche: Generate all recommendations.")
+    generate_adoption_integration_recommendations()
+    generate_all_engagement_recommendations()
+    generate_content_quality_recommendations()
+    generate_all_urban_recommendations()
+    generate_all_reflection_recommendations()
+
+def _execute_initiation_integration_rec():
+    logger.info("Début de l'exécution de la tâche: Initiation Integration.")
+    generate_adoption_integration_recommendations()
+
+def _execute_engagement_promotion_rec():
+    logger.info("Début de l'exécution de la tâche: Engagement Promotion.")
+    from modules.generate_recommendations import verifier_engagement_utilisateur
+    generate_all_engagement_recommendations()
+
+def _execute_quality_improvement_rec():
+    logger.info("Début de l'exécution de la tâche: Quality Improvement.")
+    generate_content_quality_recommendations()
+
+def _execute_urban_discovery_rec():
+    logger.info("Début de l'exécution de la tâche: Urban Discovery.")
+    generate_all_urban_recommendations()
+
+def _execute_interaction_reflection_rec():
+    logger.info("Début de l'exécution de la tâche: Interaction Reflection.")
+    generate_all_reflection_recommendations()
+
+def _execute_send_notifications():
+    logger.info("Début de l'exécution de la tâche: Send Notifications.")
+    from modules.notification_service import send_recommendation_notifications
+    send_recommendation_notifications()
+
+def _execute_full_process():
+    logger.info("Début de l'exécution de la tâche: Full Process (Récupération, Calcul, Recommandations, Notifications).")
+
+    _execute_fetch_raw_data()
+    _execute_construct_user_profiles()
+    _execute_calculate_metrics(category=None)
+    _execute_initiation_integration_rec()
+    _execute_engagement_promotion_rec()
+    _execute_quality_improvement_rec()
+    _execute_urban_discovery_rec()
+    # _execute_send_notifications()
+
+    logger.info("Tâche Full Process exécutée avec succès.")
+
+def add_scheduled_jobs():
+    """Ajoute ou met à jour les tâches planifiées en fonction des enregistrements dans la table `ScheduledTask`."""
+    with current_app.app_context():
+        tasks = ScheduledTask.query.all()
+        for task in tasks:
+            trigger = _get_task_trigger(task)
+            if trigger:
+                scheduler.add_job(
+                    func=execute_task_function,
+                    trigger=trigger,
+                    id=str(task.id),
+                    replace_existing=True,
+                    args=[task.id]
+                )
+
+def _get_task_trigger(task):
+    """Retourne le trigger APScheduler correspondant à une tâche planifiée."""
+    if task.recurrence == 'daily':
+        return IntervalTrigger(days=1, start_date=task.scheduled_time)
+    elif task.recurrence == 'weekly':
+        return IntervalTrigger(weeks=1, start_date=task.scheduled_time)
+    elif task.recurrence == 'monthly':
+        return CronTrigger(day='1', start_date=task.scheduled_time)
+    elif task.recurrence == 'custom':
+        return CronTrigger.from_crontab(task.recurrence, start_date=task.scheduled_time)
+    else:
+        return DateTrigger(run_date=task.scheduled_time)
+
+def reload_scheduled_jobs():
+    """Recharge toutes les tâches planifiées à partir de la base de données."""
+    with current_app.app_context():
+        scheduler.remove_all_jobs()
+        add_scheduled_jobs()
+
+def delete_all_scheduled_tasks():
+    """Supprime toutes les tâches planifiées et les enregistrements correspondants de la base de données."""
+    try:
+        scheduler.remove_all_jobs()
+        db.session.query(ScheduledTask).delete()
+        db.session.commit()
+        reload_scheduled_jobs()
+        return {'success': True}
+    except Exception as e:
+        logger.error(f"Erreur lors de la suppression de toutes les tâches planifiées: {e}")
+        return {'success': False}
diff --git a/rs/web_app/templates/action_details.html b/rs/web_app/templates/action_details.html
new file mode 100644
index 0000000000000000000000000000000000000000..be5de64e84d4d34f66f44704c167cef5107e8538
--- /dev/null
+++ b/rs/web_app/templates/action_details.html
@@ -0,0 +1,105 @@
+<div class="modal fade" id="detailsModal" tabindex="-1" role="dialog" aria-labelledby="detailsModalLabel" aria-hidden="true">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="detailsModalLabel">Détails de cette catégorie</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+
+                <!-- Conteneur pour les filtres avec style moderne -->
+                <div class="card p-3 mb-3 shadow-sm">
+                    <div class="d-flex flex-wrap align-items-center mb-2">
+                        <!-- Liste déroulante pour sélectionner plusieurs utilisateurs avec Select2 -->
+                        <div class="form-group mr-2 flex-grow-1 mb-0">
+                            <select id="userSelect" class="form-control select2-custom" multiple>
+                                <option value="all">All</option>
+                                {% for user in users %}
+                                    <option value="{{ user }}">{{ user }}</option>
+                                {% endfor %}
+                            </select>
+                        </div>
+
+                        <!-- Liste déroulante pour sélectionner la période de temps avec Select2 -->
+                        <div class="form-group mr-2 flex-grow-1 mb-0">
+                            <select id="timeSelect" class="form-control select2-custom">
+                                <option value="">Sélectionnez une période</option>
+                                <option value="today">Aujourd'hui</option>
+                                <option value="this_week">Cette semaine</option>
+                                <option value="this_month">Ce mois-ci</option>
+                                <option value="this_year">Cette année</option>
+                                <option value="custom">Personnalisée</option>
+                            </select>
+                        </div>
+                    </div>
+
+                    <!-- Champs de date pour la période personnalisée (affichés en ligne) -->
+                    <div id="customDateRange" class="d-none mt-2">
+                        <div class="d-flex">
+                            <div class="form-group mr-2 flex-grow-1 mb-0">
+                                <label for="startDate" class="text-muted small">Date de début</label>
+                                <input type="date" id="startDate" class="form-control rounded">
+                            </div>
+                            <div class="form-group flex-grow-1 mb-0">
+                                <label for="endDate" class="text-muted small">Date de fin</label>
+                                <input type="date" id="endDate" class="form-control rounded">
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                
+                <!-- Liste déroulante pour sélectionner le type de données -->
+                <select id="typeSelect" class="form-control mb-3">
+                    <option value="">Sélectionnez un type de données</option>
+                    {% for type in types %}
+                        <option value="{{ type }}">{{ type }}</option>
+                    {% endfor %}
+                </select>
+
+                <!-- Liste déroulante pour sélectionner le type de visualisation -->
+                <select id="chartTypeSelect1" class="form-control" style="display: none;">
+                    <!-- Options dynamiques pour les types de visualisation -->
+                </select>
+                <div id="chartTypeSelect" class="btn-group" role="group"></div>
+
+
+                <!-- Conteneur de graphique -->
+                <div id="chartContainer" style="display: none;">
+                    <canvas id="metricsChart"></canvas>
+                </div>
+
+                <!-- Conteneur de table de données -->
+                <div id="dataTableContainer" style="display: none;">
+                    <!-- Tableau de données rempli dynamiquement -->
+                    <table class="table" id="dataTable">
+                        <thead>
+                            <tr>
+                                {% for key in data[0].keys() %}
+                                    <th>{{ key }}</th>
+                                {% endfor %}
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for row in data %}
+                                <tr class="data-row" data-type="{{ row['type'] }}">
+                                    {% for value in row.values() %}
+                                        <td>{{ value }}</td>
+                                    {% endfor %}
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+
+                <!-- Conteneur pour les données JSON -->
+                <script id="jsonDataScript" type="application/json">
+                    {{ data | tojson | safe }}
+                </script>
+
+                
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/rs/web_app/templates/base.html b/rs/web_app/templates/base.html
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/rs/web_app/templates/dashboard.html b/rs/web_app/templates/dashboard.html
new file mode 100644
index 0000000000000000000000000000000000000000..6618e431c62a7ef08427c002b8f56439ce68a8a2
--- /dev/null
+++ b/rs/web_app/templates/dashboard.html
@@ -0,0 +1,611 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <title>MOBILES Recommendation Engine</title>
+        <!-- jQuery -->
+        <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
+
+        <!-- Bootstrap JS -->
+        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
+        <link rel="icon" href="{{ url_for('static', filename='anrmobiles.png') }}" type="image/png">
+        
+        <!-- Bootstrap CSS -->
+        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
+        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.5.0/font/bootstrap-icons.min.css">
+        
+        <!-- Font Awesome CSS -->
+        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
+
+
+        <!-- CSS et JS Select2 -->
+        <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
+        <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
+
+
+        
+        <!-- Custom Styles -->
+        <link rel="stylesheet" href="../static/styles.css">
+
+        <script src="{{ url_for('static', filename='js/metrics_charts.js') }}"></script>
+        <script src="{{ url_for('static', filename='js/recos_charts.js') }}"></script>
+        <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+
+        
+        
+        
+    </head>
+<body>
+    <!-- Header -->
+    <header class="header">
+        <img src="{{ url_for('static', filename='anrmobiles.png') }}" alt="MOBILES Logo">
+        <h1>MOBILES Recommendation Engine</h1>
+    </header>
+    <!-- Task Manager Button -->
+    <button id="taskManagerButton"><i class="fas fa-tasks"></i></button>
+
+     <!-- Full Process Section -->
+     <div class="full-process-section">
+        <div class="container">
+            <div class="full-process-header">
+                <i class="fas fa-cogs full-process-icon"></i>
+                <h2>The Full Process</h2>
+                
+                
+                <p>This action runs the entire recommendation pipeline sequentially, integrating data retrieval, metrics calculation, recommendation generation, and notification dispatch to deliver a comprehensive set of recommendations.</p>
+                <p class="task-stats">
+                    <strong>Status:</strong> <span id="task-status-full_process" class="status inactive">Inactive</span> | 
+                    <strong>Last Execution:</strong> <span id="task-last-run-full_process">Not yet run</span>
+                </p>
+
+                <div class="task-button-group">
+                    <a href="#" class="btn btn-success execute-task" data-task-id="full_process">Run Full Process Now</a>
+                    <button type="button" class="btn btn-primary schedule-all-tasks" data-toggle="modal" data-target="#scheduleModal" data-task="full_process">Schedule Full Process</button>
+                    <button type="button" class="btn btn-danger delete-all-tasks">Delete All Scheduled Tasks</button>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <!-- Sidebar -->
+    <div id="taskManagerSidebar" class="sidebar">
+        
+        <h3 class="text-light text-center">Crontab</h3>
+        <div class="task-list">
+            <!-- Dynamically generated task items -->
+            {% for task in tasks %}
+            <div class="task-item">
+                <h5>{{ task.name }}</h5>
+                <p><strong>Next Run:</strong> {{ task.scheduled_time }} <br/> <strong>Recurrence:</strong> {{ task.recurrence }}</p>
+                
+
+                <div class="task-action">
+                    <a  class="btn btn-sm" 
+                    data-toggle="modal" 
+                    data-target="#scheduleModal" 
+                    data-task="{{ task.name }}" 
+                    data-task-id="{{ task.id }}" 
+                    data-mode="edit" 
+                    data-scheduled-time="{{ task.scheduled_time.isoformat() }}" 
+                    data-recurrence="{{ task.recurrence }}" 
+                    data-custom-recurrence="{{ task.custom_recurrence }}" 
+                    data-task-name="{{ task.name }}">Edit</a>
+                    
+
+                    <a href="{{ url_for('web_app.delete_task', task_id=task.id) }}" class="btn btn-sm">Delete</a>
+                </div>
+            </div>
+            {% endfor %}
+        </div>
+    </div>
+
+    <!-- Loader Element -->
+    <div id="loadingIndicator" style="display:none;">
+        <div class="spinner-border" role="status">
+            <span class="sr-only">Loading...</span>
+        </div>
+        <p>Task is being executed. Please wait...</p>
+    </div>
+
+
+    <div id="task-container" class="container">
+        <script>
+            const tasks = [
+                    {
+                        id: 'fetch_raw_data',
+                        type: 'data',
+                        title: 'Fetch and Preprocess Data',
+                        icon: 'fas fa-database',
+                        description: 'This task fetches raw data from external sources and processes it to store in the database.',
+                        statusId: 'task-status-fetch_raw_data',
+                        lastRunId: 'task-last-run-fetch_raw_data',
+                        category: 'Prepare Data'
+                    },
+                    {
+                        id: 'construct_user_profiles',
+                        type: 'data',
+                        title: 'Construct User Profiles',
+                        icon: 'fas fa-user',
+                        description: 'This task construct user profiles from the data and store them in the database.',
+                        statusId: 'task-status-construct_user_profiles',
+                        lastRunId: 'task-last-run-construct_user_profiles',
+                        category: 'Prepare Data'
+                    },
+                    {
+                        id: 'calculate_adoption_metrics',
+                        type: 'metrics',
+                        title: 'Calculate Adoption Metrics',
+                        icon: 'fas fa-user-check',
+                        description: 'This task calculates adoption metrics.',
+                        statusId: 'task-status-calculate_adoption_metrics',
+                        lastRunId: 'task-last-run-calculate_adoption_metrics',
+                        category: 'Calculate Metrics'
+                    },
+                    {
+                        id: 'calculate_engagement_metrics',
+                        type: 'metrics',
+                        title: 'Calculate Engagement Metrics',
+                        icon: 'fas fa-heart',
+                        description: 'This task calculates engagement metrics.',
+                        statusId: 'task-status-calculate_engagement_metrics',
+                        lastRunId: 'task-last-run-calculate_engagement_metrics',
+                        category: 'Calculate Metrics'
+                    },
+                    {
+                        id: 'calculate_quality_metrics',
+                        type: 'metrics',
+                        title: 'Calculate Quality Metrics',
+                        icon: 'fas fa-star',
+                        description: 'This task calculates quality metrics.',
+                        statusId: 'task-status-calculate_quality_metrics',
+                        lastRunId: 'task-last-run-calculate_quality_metrics',
+                        category: 'Calculate Metrics'
+                    },
+                    {
+                        id: 'calculate_urban_discovery_metrics',
+                        type: 'metrics',
+                        title: 'Calculate Urban Discovery Metrics',
+                        icon: 'fas fa-city',
+                        description: 'This task calculates urban discovery metrics.',
+                        statusId: 'task-status-calculate_urban_discovery_metrics',
+                        lastRunId: 'task-last-run-calculate_urban_discovery_metrics',
+                        category: 'Calculate Metrics'
+                    },
+                    // {
+                    //     id: 'calculate_interaction_reflection_metrics',
+                    //     type: 'metrics',
+                    //     title: 'Calculate Interaction & Reflection Metrics',
+                    //     icon: 'fas fa-comments',
+                    //     description: 'This task calculates interaction and reflection metrics.',
+                    //     statusId: 'task-status-calculate_interaction_reflection_metrics',
+                    //     lastRunId: 'task-last-run-calculate_interaction_reflection_metrics',
+                    //     category: 'Calculate Metrics'
+                    // },
+                    {
+                        id: 'initiation_integration',
+                        type: 'recos',
+                        title: 'Initiation Integration',
+                        icon: 'fas fa-users',
+                        description: 'This task provides personalized recommendations to support new users as they onboard and integrate into the application.',
+                        statusId: 'task-status-initiation_integration',
+                        lastRunId: 'task-last-run-initiation_integration',
+                        category: 'Generate Recommendations'
+                    },
+                    {
+                        id: 'engagement_promotion',
+                        type: 'recos',
+                        title: 'Engagement Promotion',
+                        icon: 'fas fa-handshake',
+                        description: 'This task generates targeted recommendations designed to enhance user engagement and encourage active participation within the application.',
+                        statusId: 'task-status-engagement_promotion',
+                        lastRunId: 'task-last-run-engagement_promotion',
+                        category: 'Generate Recommendations'
+                    },
+                    {
+                        id: 'quality_improvement',
+                        type: 'recos',
+                        title: 'Quality Improvement',
+                        icon: 'fas fa-chart-line',
+                        description: 'This task delivers recommendations aimed at enhancing the quality of user contributions by identifying and suggesting improvements for content accuracy and relevance.',
+                        statusId: 'task-status-quality_improvement',
+                        lastRunId: 'task-last-run-quality_improvement',
+                        category: 'Generate Recommendations'
+                    },
+                    {
+                        id: 'urban_discovery',
+                        type: 'recos',
+                        title: 'Urban Discovery',
+                        icon: 'fas fa-city',
+                        description: 'This task offers recommendations to help users explore and uncover urban trends, insights, and points of interest.',
+                        statusId: 'task-status-urban_discovery',
+                        lastRunId: 'task-last-run-urban_discovery',
+                        category: 'Generate Recommendations'
+                    },
+                    // {
+                    //     id: 'interaction_reflection',
+                    //     type: 'recos',
+                    //     title: 'Interaction Reflection',
+                    //     icon: 'fas fa-comments',
+                    //     description: 'This task provides recommendations that encourage meaningful user interactions and support reflection on engagement patterns.',
+                    //     statusId: 'task-status-interaction_reflection',
+                    //     lastRunId: 'task-last-run-interaction_reflection',
+                    //     category: 'Generate Recommendations'
+                    // },
+                    {
+                        id: 'send_notifications',
+                        title: 'Send Notifications',
+                        icon: 'fas fa-envelope',
+                        description: 'This task sends out notifications based on the latest recommendation data.',
+                        statusId: 'task-status-send_notifications',
+                        lastRunId: 'task-last-run-send_notifications',
+                        category: 'Send Notifications'
+                    }
+                ];
+
+         // Organiser les tâches par catégories
+         
+         const categories = tasks.reduce((acc, task) => {
+                if (!acc[task.category]) {
+                    acc[task.category] = [];
+                }
+                acc[task.category].push(task);
+                return acc;
+            }, {});
+
+            // Créer les sections par catégories
+            Object.keys(categories).forEach(category => {
+                const tasksInCategory = categories[category];
+
+                document.write(`
+                    <section class="category-section">
+                        <div class="category-header">
+                            <h2 class="category-title">${category}</h2>
+                            ${tasksInCategory.length > 1 ? `
+                            <div class="category-actions">
+                                <div class="task-summary">
+                                    <div class="d-flex align-items-center">
+                                        <i class="fas fa-tachometer-alt task-icon"></i>
+                                        
+                                        <div>
+                                            <div class="task-title">All Actions </div>
+                                            <p class="task-stats">
+                                                <strong>Status:</strong> <span id="task-status-${category.replace(/\s+/g, '_').toLowerCase()}" class="status inactive">Inactive</span> |
+                                                <strong>Last Execution:</strong> <span id="task-last-run-${category.replace(/\s+/g, '_').toLowerCase()}" >Not yet run</span>
+                                            </p>
+                                        </div>
+                                    </div>
+                                    <div class="task-button-group">
+                                        <a href="#" class="btn btn-danger execute-all" data-task-id="${category.replace(/\s+/g, '_').toLowerCase()}">Execute All</a>
+                                        <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#scheduleModal" data-task="${category.replace(/\s+/g, '_').toLowerCase()}">Schedule All</button>
+                                    </div>
+                                </div>
+                            </div>
+                            ` : ''}
+                        </div>
+                        
+                        <div class="task-list">
+                            <div class="row">
+                `);
+
+                tasksInCategory.forEach(task => {
+                    document.write(`
+                        <div class="col-md-6 mb-3">
+                            <div class="task-card">
+                                <div class="d-flex align-items-center">
+                                    <i class="${task.icon} task-icon"></i>
+                                    <div>
+                                        <div class="task-title">${task.title}</div>
+                                        <p class="task-stats">
+                                            <strong>Status:</strong> <span id="${task.statusId}" class="status inactive">Inactive</span> |
+                                            <strong>Last Execution:</strong> <span id="${task.lastRunId}">Not yet run</span>
+                                        </p>
+                                    </div>
+                                </div>
+                                <p>${task.description}</p>
+                                <div class="task-button-group">
+                                    <a href="#" class="btn btn-success execute-task" data-task-id="${task.id}">Run</a>
+                                    <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#scheduleModal" data-task="${task.id}">Schedule</button>
+                                    ${task.type === 'metrics' ? `<button type="button" class="btn btn-warning metrics-details" data-task="${task.id}"><i>Logs</i></button>` : 
+                                    task.type === 'recos' ? `<button type="button" class="btn btn-warning recos-details" data-task="${task.id}"><i>Logs</i></button>` : ''}
+                                </div>
+                            </div>
+                        </div>
+                    `);
+                });
+
+                document.write(`
+                            </div>
+                        </div>
+                    </section>
+                `);
+            });
+        </script>
+    </div>
+    
+    
+
+    <!-- Schedule Modal -->
+    <div class="modal fade" id="scheduleModal" tabindex="-1" aria-labelledby="scheduleModalLabel" aria-hidden="true">
+        <div class="modal-dialog">
+            <div class="modal-content">
+                <!-- Modal Header -->
+                <div class="modal-header">
+                    <h5 class="modal-title" id="scheduleModalLabel">Schedule Task</h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <!-- Modal Body -->
+                <div class="modal-body">
+                    <form id="taskForm" action="{{ url_for('web_app.schedule_task') }}" method="POST">
+                        <!-- Hidden field for task action (type) -->
+                        <input type="hidden" id="taskAction" name="task_action">
+                        <input type="hidden" id="taskId" name="task_id">
+                        <input type="hidden" id="formMode" name="form_mode" value="create">
+                        
+                        <div class="form-group">
+                            <label for="taskName">Task Name</label>
+                            <input type="text" class="form-control" id="taskName" name="task_name" required>
+                        </div>
+
+                        <div class="form-group">
+                            <label for="scheduleTime">Scheduled Time</label>
+                            <input type="datetime-local" class="form-control" id="scheduleTime" name="scheduled_time" required>
+                        </div>
+                        <div class="form-group">
+                            <label for="recurrence">Recurrence</label>
+                            <select class="form-control" id="recurrence" name="recurrence">
+                                <option value="none">None</option>
+                                <option value="daily">Daily</option>
+                                <option value="weekly">Weekly</option>
+                                <option value="monthly">Monthly</option>
+                                <!-- <option value="custom">Custom</option> -->
+                            </select>
+                        </div>
+                        <div id="customRecurrenceGroup" class="form-group" style="display:none;">
+                            <label for="customRecurrence">Custom Recurrence (Cron format)</label>
+                            <input type="text" class="form-control" id="customRecurrence" name="custom_recurrence" placeholder="e.g., 0 0 * * *">
+                        </div>
+                        <button type="submit" class="btn btn-primary">Save</button>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+
+    <div id="detailsModalContainer"></div>
+    <div id="recosDetailsModalContainer"></div>
+
+    <!-- Modal pour les alertes -->
+<div class="modal fade" id="alertModal" tabindex="-1" role="dialog" aria-labelledby="alertModalLabel" aria-hidden="true">
+    <div class="modal-dialog" role="document">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h5 class="modal-title" id="alertModalLabel">Pas de données !</h5>
+          <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+            <span aria-hidden="true">&times;</span>
+          </button>
+        </div>
+        <div class="modal-body">
+          <p id="alertMessage">Aucun détail à afficher pour cette catégorie. Avez-vous au prélable calculé ces métriques ?</p>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-secondary" data-dismiss="modal">Fermer</button>
+        </div>
+      </div>
+    </div>
+  </div>
+  
+    <script>
+        
+        /* Sidebar handling */
+        function openNav() {
+            document.getElementById("taskManagerSidebar").style.width = "350px";
+        }
+
+        function closeNav() {
+            document.getElementById("taskManagerSidebar").style.width = "0";
+        }
+
+        document.getElementById("taskManagerButton").onclick = function() {
+            if (document.getElementById("taskManagerSidebar").style.width === "350px") {
+                closeNav();
+            } else {
+                openNav();
+            }
+        };
+
+        // Gestionnaire de clic en dehors de la sidebar
+        document.addEventListener("click", function(event) {
+            var sidebar = document.getElementById("taskManagerSidebar");
+            var button = document.getElementById("taskManagerButton");
+
+            // Vérifiez si le clic est à l'extérieur de la sidebar et du bouton
+            if (!sidebar.contains(event.target) && !button.contains(event.target)) {
+                closeNav();
+            }
+        });
+
+        /* Schedule modal handling */
+        $('#scheduleModal').on('show.bs.modal', function (event) {
+        var button = $(event.relatedTarget);
+        var taskAction = button.data('task');
+        var taskName = button.data('task-name');
+        var taskId = button.data('task-id');
+        var mode = button.data('mode');
+        var modal = $(this);
+
+        // Définir le type de tâche immuable
+        modal.find('#taskAction').val(taskAction);
+
+        // Définir le nom de la tâche en fonction du type de tâche pour le mode de création
+        if (mode === 'edit') {
+            modal.find('#formMode').val('edit');
+            modal.find('#taskId').val(taskId);
+            modal.find('#taskName').val(taskName);
+            modal.find('#scheduleTime').val(button.data('scheduled-time'));
+            modal.find('#recurrence').val(button.data('recurrence'));
+
+            if (button.data('recurrence') === 'custom') {
+                modal.find('#customRecurrenceGroup').show();
+                modal.find('#customRecurrence').val(button.data('custom-recurrence'));
+            } else {
+                modal.find('#customRecurrenceGroup').hide();
+            }
+        } else {
+            modal.find('#formMode').val('create');
+            modal.find('#taskId').val('');
+
+            // Définit une valeur par défaut pour le nom de la tâche en fonction du type de tâche
+            var defaultTaskName = 'Task for ' + taskAction.charAt(0).toUpperCase() + taskAction.slice(1);
+            modal.find('#taskName').val(defaultTaskName);
+
+            modal.find('#scheduleTime').val('');
+            modal.find('#recurrence').val('none');
+            modal.find('#customRecurrenceGroup').hide();
+            modal.find('#customRecurrence').val('');
+        }
+    });
+
+    $('#recurrence').change(function() {
+        if ($(this).val() === 'custom') {
+            $('#customRecurrenceGroup').show();
+        } else {
+            $('#customRecurrenceGroup').hide();
+        }
+    });
+
+    document.addEventListener('DOMContentLoaded', function () {
+        // Gestionnaire pour les boutons "Execute Now"
+        document.querySelectorAll('.execute-task').forEach(button => {
+            
+            button.addEventListener('click', function (event) {
+                event.preventDefault(); // Empêche le comportement par défaut
+                
+                var taskId = this.dataset.taskId;
+                var statusElement = document.getElementById(`task-status-${taskId}`);
+                var lastRunElement = document.getElementById(`task-last-run-${taskId}`);
+                
+                // Met à jour l'état de la tâche à "En cours"
+                statusElement.textContent = 'In Progress';
+                statusElement.classList.remove('inactive');
+                statusElement.classList.add('in-progress');
+                lastRunElement.textContent = 'Running...';
+
+                // Appel AJAX pour exécuter la tâche
+                fetch(`/execute_task/${taskId}`, { method: 'POST' })
+                    .then(response => response.json())
+                    .then(data => {
+                        if (data.success) {
+                            statusElement.textContent = 'Success';
+                            statusElement.classList.remove('in-progress');
+                            statusElement.classList.add('success');
+                            lastRunElement.textContent = 'Completed successfully';
+                        } else {
+                            statusElement.textContent = 'Failed';
+                            statusElement.classList.remove('in-progress');
+                            statusElement.classList.add('failed');
+                            lastRunElement.textContent = 'Execution failed';
+                        }
+                    })
+                    .catch(error => {
+                        statusElement.textContent = 'Failed';
+                        statusElement.classList.remove('in-progress');
+                        statusElement.classList.add('failed');
+                        lastRunElement.textContent = 'Execution failed';
+                        console.error('Error:', error);
+                    });
+            });
+        });
+
+        document.querySelectorAll('.execute-all').forEach(button => {
+            
+            button.addEventListener('click', function (event) {
+                event.preventDefault(); // Empêche le comportement par défaut
+                
+                var taskId = this.dataset.taskId;
+                var statusElement = document.getElementById(`task-status-${taskId}`);
+                var lastRunElement = document.getElementById(`task-last-run-${taskId}`);
+                
+                // Met à jour l'état de la tâche à "En cours"
+                statusElement.textContent = 'In Progress';
+                statusElement.classList.remove('inactive');
+                statusElement.classList.add('in-progress');
+                lastRunElement.textContent = 'Running...';
+
+                // Appel AJAX pour exécuter la tâche
+                fetch(`/execute_task/${taskId}`, { method: 'POST' })
+                    .then(response => response.json())
+                    .then(data => {
+                        if (data.success) {
+                            statusElement.textContent = 'Success';
+                            statusElement.classList.remove('in-progress');
+                            statusElement.classList.add('success');
+                            lastRunElement.textContent = 'Completed successfully';
+                        } else {
+                            statusElement.textContent = 'Failed';
+                            statusElement.classList.remove('in-progress');
+                            statusElement.classList.add('failed');
+                            lastRunElement.textContent = 'Execution failed';
+                        }
+                    })
+                    .catch(error => {
+                        statusElement.textContent = 'Failed';
+                        statusElement.classList.remove('in-progress');
+                        statusElement.classList.add('failed');
+                        lastRunElement.textContent = 'Execution failed';
+                        console.error('Error:', error);
+                    });
+            });
+        });
+
+            // Handle Schedule All button clicks
+            document.querySelectorAll('.schedule-all').forEach(button => {
+                button.addEventListener('click', function() {
+                    const category = this.getAttribute('data-category');
+                    // Logic to schedule all tasks in the category
+                    console.log(`Scheduling all tasks in category: ${category}`);
+                });
+            });
+
+            // Confirm schedule all tasks in modal
+            // document.getElementById('schedule-all-confirm').addEventListener('click', function() {
+            //     const category = document.querySelector('#scheduleAllModal').getAttribute('data-category');
+            //     // Logic to schedule all tasks in the selected category
+            //     console.log(`Confirmed scheduling all tasks in category: ${category}`);
+            // });
+    });
+
+    document.querySelector('.delete-all-tasks').addEventListener('click', function() {
+    if (confirm('Are you sure you want to delete all scheduled tasks? This action cannot be undone.')) {
+        fetch('/delete_all_scheduled_tasks', {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json',
+            },
+        })
+        .then(response => response.json())
+        .then(data => {
+            if (data.success) {
+                alert('All scheduled tasks have been successfully deleted.');
+                location.reload();
+            } else {
+                alert('An error occurred while deleting the tasks. Please try again.');
+            }
+        })
+        .catch(error => console.error('Error:', error));
+    }
+});
+
+
+
+
+
+
+  </script>
+
+  
+</body>
+</html>
diff --git a/rs/web_app/templates/login.html b/rs/web_app/templates/login.html
new file mode 100644
index 0000000000000000000000000000000000000000..493523a2c6c9c8caacac500db1d8803cff61066d
--- /dev/null
+++ b/rs/web_app/templates/login.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Connexion au moteur de recommandation</title>
+    <style>
+        body {
+            font-family: Arial, sans-serif;
+            background-color: #f4f4f4;
+            display: flex;
+            justify-content: center;
+            align-items: center;
+            height: 100vh;
+            margin: 0;
+        }
+        .container {
+            background: #fff;
+            padding: 2rem;
+            border-radius: 8px;
+            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+            width: 100%;
+            max-width: 400px;
+            text-align: center;
+        }
+        h2 {
+            color: #333;
+            margin-bottom: 1rem;
+        }
+        .message {
+            background-color: #eaf0f1;
+            border: 1px solid #d6e0e1;
+            border-radius: 4px;
+            padding: 0.5rem;
+            margin-bottom: 1rem;
+            color: #2e3a34;
+            list-style: none;
+            text-align: left;
+        }
+        .message.error {
+            background-color: #f8d7da;
+            border-color: #f5c6cb;
+            color: #721c24;
+        }
+        .message.success {
+            background-color: #d4edda;
+            border-color: #c3e6cb;
+            color: #155724;
+        }
+        form {
+            display: flex;
+            flex-direction: column;
+        }
+        input[type="password"] {
+            padding: 0.75rem;
+            border: 1px solid #ccc;
+            border-radius: 4px;
+            margin-bottom: 1rem;
+            font-size: 1rem;
+        }
+        button {
+            padding: 0.75rem;
+            border: none;
+            border-radius: 4px;
+            background-color: #007bff;
+            color: #fff;
+            font-size: 1rem;
+            cursor: pointer;
+            transition: background-color 0.3s ease;
+        }
+        button:hover {
+            background-color: #0056b3;
+        }
+    </style>
+</head>
+<body>
+  <div class="container">
+      <h2>Entrer le Code Secret</h2>
+      {% with messages = get_flashed_messages(with_categories=true) %}
+        {% if messages %}
+          <ul>
+            {% for category, message in messages %}
+              <li class="message {{ category }}">{{ message }}</li>
+            {% endfor %}
+          </ul>
+        {% endif %}
+      {% endwith %}
+      <form method="post" action="/login">
+          <input type="password" name="code" placeholder="Code Secret" required>
+          <button type="submit">Valider</button>
+      </form>
+  </div>
+</body>
+</html>
diff --git a/rs/web_app/templates/reco_details.html b/rs/web_app/templates/reco_details.html
new file mode 100644
index 0000000000000000000000000000000000000000..78a48f0d2cbe243fc8794e34e1e915dfd2812f8a
--- /dev/null
+++ b/rs/web_app/templates/reco_details.html
@@ -0,0 +1,94 @@
+<div class="modal fade" id="recosDetailsModal" tabindex="-1" role="dialog" aria-labelledby="recosDetailsModalLabel" aria-hidden="true">
+    <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="recosDetailsModalLabel">Détails de cette catégorie</h5>
+                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+
+                <!-- Conteneur pour les filtres avec style moderne -->
+                <div class="card p-3 mb-3 shadow-sm">
+                    <div class="d-flex flex-wrap align-items-center mb-2">
+                        <!-- Liste déroulante pour sélectionner plusieurs utilisateurs avec Select2 -->
+                        <div class="form-group mr-2 flex-grow-1 mb-0">
+                            <select id="recoUserSelect" class="form-control select2-custom" multiple>
+                                <option value="all">All</option>
+                                {% for user in users %}
+                                    <option value="{{ user }}">{{ user }}</option>
+                                {% endfor %}
+                            </select>
+                        </div>
+
+                        <!-- Liste déroulante pour sélectionner la période de temps avec Select2 -->
+                        <div class="form-group mr-2 flex-grow-1 mb-0">
+                            <select id="recoTimeSelect" class="form-control select2-custom">
+                                <option value="">Sélectionnez une période</option>
+                                <option value="today">Aujourd'hui</option>
+                                <option value="this_week">Cette semaine</option>
+                                <option value="this_month">Ce mois-ci</option>
+                                <option value="this_year">Cette année</option>
+                                <option value="custom">Personnalisée</option>
+                            </select>
+                        </div>
+                    </div>
+
+                    <!-- Champs de date pour la période personnalisée (affichés en ligne) -->
+                    <div id="customDateRange" class="d-none mt-2">
+                        <div class="d-flex">
+                            <div class="form-group mr-2 flex-grow-1 mb-0">
+                                <label for="startDate" class="text-muted small">Date de début</label>
+                                <input type="date" id="startDate" class="form-control rounded">
+                            </div>
+                            <div class="form-group flex-grow-1 mb-0">
+                                <label for="endDate" class="text-muted small">Date de fin</label>
+                                <input type="date" id="endDate" class="form-control rounded">
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                
+                <!-- Liste déroulante pour sélectionner le type de données -->
+                <select id="recoTypeSelect" class="form-control mb-3">
+                    <option value="">Sélectionnez un type de données</option>
+                    {% for type in types %}
+                        <option value="{{ type }}">{{ type }}</option>
+                    {% endfor %}
+                </select>
+
+
+                <!-- Conteneur de table de données -->
+                <div id="recoDataTableContainer" style="display: none;">
+                    <!-- Tableau de données rempli dynamiquement -->
+                    <table class="table" id="recoDataTable">
+                        <thead>
+                            <tr>
+                                {% for key in data[0].keys() %}
+                                    <th>{{ key }}</th>
+                                {% endfor %}
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for row in data %}
+                                <tr class="data-row" data-type="{{ row['type'] }}">
+                                    {% for value in row.values() %}
+                                        <td>{{ value }}</td>
+                                    {% endfor %}
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+
+                <!-- Conteneur pour les données JSON -->
+                <script id="jsonRecoDataScript" type="application/json">
+                    {{ data | tojson | safe }}
+                </script>
+
+                
+            </div>
+        </div>
+    </div>
+</div>
diff --git a/setup/Dockerfile b/setup/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..5eac7086233fa1e7d65dc7ebb6b48dd8e2b4fa27
--- /dev/null
+++ b/setup/Dockerfile
@@ -0,0 +1,10 @@
+ARG ELASTIC_VERSION
+
+# https://www.docker.elastic.co/
+FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION}
+
+ENTRYPOINT ["/entrypoint.sh"]
+
+COPY ./init-script.sh /usr/share/elasticsearch/config/init-script.sh
+CMD ["/bin/bash", "-c", "/usr/share/elasticsearch/config/init-script.sh && /usr/local/bin/docker-entrypoint.sh"]
+
diff --git a/setup/entrypoint.sh b/setup/entrypoint.sh
new file mode 100644
index 0000000000000000000000000000000000000000..ac79321a86ac9ba86b069863c5fba0791deda123
--- /dev/null
+++ b/setup/entrypoint.sh
@@ -0,0 +1,119 @@
+#!/usr/bin/env bash
+
+set -eu
+set -o pipefail
+
+source "${BASH_SOURCE[0]%/*}"/lib.sh
+
+
+# --------------------------------------------------------
+# Users declarations
+
+declare -A users_passwords
+users_passwords=(
+	[logstash_internal]="${LOGSTASH_INTERNAL_PASSWORD:-}"
+	[kibana_system]="${KIBANA_SYSTEM_PASSWORD:-}"
+	[metricbeat_internal]="${METRICBEAT_INTERNAL_PASSWORD:-}"
+	[filebeat_internal]="${FILEBEAT_INTERNAL_PASSWORD:-}"
+	[heartbeat_internal]="${HEARTBEAT_INTERNAL_PASSWORD:-}"
+	[monitoring_internal]="${MONITORING_INTERNAL_PASSWORD:-}"
+	[beats_system]="${BEATS_SYSTEM_PASSWORD=:-}"
+)
+
+declare -A users_roles
+users_roles=(
+	[logstash_internal]='logstash_writer'
+	[metricbeat_internal]='metricbeat_writer'
+	[filebeat_internal]='filebeat_writer'
+	[heartbeat_internal]='heartbeat_writer'
+	[monitoring_internal]='remote_monitoring_collector'
+)
+
+# --------------------------------------------------------
+# Roles declarations
+
+declare -A roles_files
+roles_files=(
+	[logstash_writer]='logstash_writer.json'
+	[metricbeat_writer]='metricbeat_writer.json'
+	[filebeat_writer]='filebeat_writer.json'
+	[heartbeat_writer]='heartbeat_writer.json'
+)
+
+# --------------------------------------------------------
+
+
+log 'Waiting for availability of Elasticsearch. This can take several minutes.'
+
+declare -i exit_code=0
+wait_for_elasticsearch || exit_code=$?
+
+if ((exit_code)); then
+	case $exit_code in
+		6)
+			suberr 'Could not resolve host. Is Elasticsearch running?'
+			;;
+		7)
+			suberr 'Failed to connect to host. Is Elasticsearch healthy?'
+			;;
+		28)
+			suberr 'Timeout connecting to host. Is Elasticsearch healthy?'
+			;;
+		*)
+			suberr "Connection to Elasticsearch failed. Exit code: ${exit_code}"
+			;;
+	esac
+
+	exit $exit_code
+fi
+
+sublog 'Elasticsearch is running'
+
+log 'Waiting for initialization of built-in users'
+
+wait_for_builtin_users || exit_code=$?
+
+if ((exit_code)); then
+	suberr 'Timed out waiting for condition'
+	exit $exit_code
+fi
+
+sublog 'Built-in users were initialized'
+
+for role in "${!roles_files[@]}"; do
+	log "Role '$role'"
+
+	declare body_file
+	body_file="${BASH_SOURCE[0]%/*}/roles/${roles_files[$role]:-}"
+	if [[ ! -f "${body_file:-}" ]]; then
+		sublog "No role body found at '${body_file}', skipping"
+		continue
+	fi
+
+	sublog 'Creating/updating'
+	ensure_role "$role" "$(<"${body_file}")"
+done
+
+for user in "${!users_passwords[@]}"; do
+	log "User '$user'"
+	if [[ -z "${users_passwords[$user]:-}" ]]; then
+		sublog 'No password defined, skipping'
+		continue
+	fi
+
+	declare -i user_exists=0
+	user_exists="$(check_user_exists "$user")"
+
+	if ((user_exists)); then
+		sublog 'User exists, setting password'
+		set_user_password "$user" "${users_passwords[$user]}"
+	else
+		if [[ -z "${users_roles[$user]:-}" ]]; then
+			suberr '  No role defined, skipping creation'
+			continue
+		fi
+
+		sublog 'User does not exist, creating'
+		create_user "$user" "${users_passwords[$user]}" "${users_roles[$user]}"
+	fi
+done
diff --git a/setup/init-script.sh b/setup/init-script.sh
new file mode 100644
index 0000000000000000000000000000000000000000..ebc63eef556f847b2bc0640ff73ffae4495b5aae
--- /dev/null
+++ b/setup/init-script.sh
@@ -0,0 +1,20 @@
+#!/bin/bash
+# Change file permissions
+chmod +x /usr/share/elasticsearch/config/init-script.sh
+# Wait for Elasticsearch to start
+until curl -s http://elasticsearch:9200/_cat/health -o /dev/null; do
+    sleep 1
+done
+# Set up the mapping
+curl -XPUT es:9200/_template/template_1 -H 'Content-Type: application/json' -d'
+{
+  "index_patterns": ["your_index_pattern"],
+  "mappings": {
+    "properties": {
+      "m:position": { "type": "geo_point" },
+      "m:coordinates": { "type": "geo_point" },
+      "m:prev_coordinates": { "type": "geo_point" }
+      // Add other field mappings as needed
+    }
+  }
+}'
\ No newline at end of file
diff --git a/setup/lib.sh b/setup/lib.sh
new file mode 100644
index 0000000000000000000000000000000000000000..e29f626fb0b080e9f60058e825dbd684d52a8c9b
--- /dev/null
+++ b/setup/lib.sh
@@ -0,0 +1,240 @@
+#!/usr/bin/env bash
+
+# Log a message.
+function log {
+	echo "[+] $1"
+}
+
+# Log a message at a sub-level.
+function sublog {
+	echo "   â ¿ $1"
+}
+
+# Log an error.
+function err {
+	echo "[x] $1" >&2
+}
+
+# Log an error at a sub-level.
+function suberr {
+	echo "   ⠍ $1" >&2
+}
+
+# Poll the 'elasticsearch' service until it responds with HTTP code 200.
+function wait_for_elasticsearch {
+	local elasticsearch_host="${ELASTICSEARCH_HOST:-es}"
+
+	local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}' "http://${elasticsearch_host}:9200/" )
+
+	if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then
+		args+=( '-u' "elastic:${ELASTIC_PASSWORD}" )
+	fi
+
+	local -i result=1
+	local output
+
+	# retry for max 300s (60*5s)
+	for _ in $(seq 1 60); do
+		local -i exit_code=0
+		output="$(curl "${args[@]}")" || exit_code=$?
+
+		if ((exit_code)); then
+			result=$exit_code
+		fi
+
+		if [[ "${output: -3}" -eq 200 ]]; then
+			result=0
+			break
+		fi
+
+		sleep 5
+	done
+
+	if ((result)) && [[ "${output: -3}" -ne 000 ]]; then
+		echo -e "\n${output::-3}"
+	fi
+
+	return $result
+}
+
+# Poll the Elasticsearch users API until it returns users.
+function wait_for_builtin_users {
+	local elasticsearch_host="${ELASTICSEARCH_HOST:-es}"
+
+	local -a args=( '-s' '-D-' '-m15' "http://${elasticsearch_host}:9200/_security/user?pretty" )
+
+	if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then
+		args+=( '-u' "elastic:${ELASTIC_PASSWORD}" )
+	fi
+
+	local -i result=1
+
+	local line
+	local -i exit_code
+	local -i num_users
+
+	# retry for max 30s (30*1s)
+	for _ in $(seq 1 30); do
+		num_users=0
+
+		# read exits with a non-zero code if the last read input doesn't end
+		# with a newline character. The printf without newline that follows the
+		# curl command ensures that the final input not only contains curl's
+		# exit code, but causes read to fail so we can capture the return value.
+		# Ref. https://unix.stackexchange.com/a/176703/152409
+		while IFS= read -r line || ! exit_code="$line"; do
+			if [[ "$line" =~ _reserved.+true ]]; then
+				(( num_users++ ))
+			fi
+		done < <(curl "${args[@]}"; printf '%s' "$?")
+
+		if ((exit_code)); then
+			result=$exit_code
+		fi
+
+		# we expect more than just the 'elastic' user in the result
+		if (( num_users > 1 )); then
+			result=0
+			break
+		fi
+
+		sleep 1
+	done
+
+	return $result
+}
+
+# Verify that the given Elasticsearch user exists.
+function check_user_exists {
+	local username=$1
+
+	local elasticsearch_host="${ELASTICSEARCH_HOST:-es}"
+
+	local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}'
+		"http://${elasticsearch_host}:9200/_security/user/${username}"
+		)
+
+	if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then
+		args+=( '-u' "elastic:${ELASTIC_PASSWORD}" )
+	fi
+
+	local -i result=1
+	local -i exists=0
+	local output
+
+	output="$(curl "${args[@]}")"
+	if [[ "${output: -3}" -eq 200 || "${output: -3}" -eq 404 ]]; then
+		result=0
+	fi
+	if [[ "${output: -3}" -eq 200 ]]; then
+		exists=1
+	fi
+
+	if ((result)); then
+		echo -e "\n${output::-3}"
+	else
+		echo "$exists"
+	fi
+
+	return $result
+}
+
+# Set password of a given Elasticsearch user.
+function set_user_password {
+	local username=$1
+	local password=$2
+
+	local elasticsearch_host="${ELASTICSEARCH_HOST:-es}"
+
+	local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}'
+		"http://${elasticsearch_host}:9200/_security/user/${username}/_password"
+		'-X' 'POST'
+		'-H' 'Content-Type: application/json'
+		'-d' "{\"password\" : \"${password}\"}"
+		)
+
+	if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then
+		args+=( '-u' "elastic:${ELASTIC_PASSWORD}" )
+	fi
+
+	local -i result=1
+	local output
+
+	output="$(curl "${args[@]}")"
+	if [[ "${output: -3}" -eq 200 ]]; then
+		result=0
+	fi
+
+	if ((result)); then
+		echo -e "\n${output::-3}\n"
+	fi
+
+	return $result
+}
+
+# Create the given Elasticsearch user.
+function create_user {
+	local username=$1
+	local password=$2
+	local role=$3
+
+	local elasticsearch_host="${ELASTICSEARCH_HOST:-es}"
+
+	local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}'
+		"http://${elasticsearch_host}:9200/_security/user/${username}"
+		'-X' 'POST'
+		'-H' 'Content-Type: application/json'
+		'-d' "{\"password\":\"${password}\",\"roles\":[\"${role}\"]}"
+		)
+
+	if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then
+		args+=( '-u' "elastic:${ELASTIC_PASSWORD}" )
+	fi
+
+	local -i result=1
+	local output
+
+	output="$(curl "${args[@]}")"
+	if [[ "${output: -3}" -eq 200 ]]; then
+		result=0
+	fi
+
+	if ((result)); then
+		echo -e "\n${output::-3}\n"
+	fi
+
+	return $result
+}
+
+# Ensure that the given Elasticsearch role is up-to-date, create it if required.
+function ensure_role {
+	local name=$1
+	local body=$2
+
+	local elasticsearch_host="${ELASTICSEARCH_HOST:-es}"
+
+	local -a args=( '-s' '-D-' '-m15' '-w' '%{http_code}'
+		"http://${elasticsearch_host}:9200/_security/role/${name}"
+		'-X' 'POST'
+		'-H' 'Content-Type: application/json'
+		'-d' "$body"
+		)
+
+	if [[ -n "${ELASTIC_PASSWORD:-}" ]]; then
+		args+=( '-u' "elastic:${ELASTIC_PASSWORD}" )
+	fi
+
+	local -i result=1
+	local output
+
+	output="$(curl "${args[@]}")"
+	if [[ "${output: -3}" -eq 200 ]]; then
+		result=0
+	fi
+
+	if ((result)); then
+		echo -e "\n${output::-3}\n"
+	fi
+
+	return $result
+}
diff --git a/setup/roles/filebeat_writer.json b/setup/roles/filebeat_writer.json
new file mode 100644
index 0000000000000000000000000000000000000000..118614bee8429584db5036cd3fb704c0ff9b2200
--- /dev/null
+++ b/setup/roles/filebeat_writer.json
@@ -0,0 +1,19 @@
+{
+  "cluster": [
+    "manage_ilm",
+    "manage_index_templates",
+    "monitor",
+    "read_pipeline"
+  ],
+  "indices": [
+    {
+      "names": [
+        "filebeat-*"
+      ],
+      "privileges": [
+        "create_doc",
+        "manage"
+      ]
+    }
+  ]
+}
diff --git a/setup/roles/heartbeat_writer.json b/setup/roles/heartbeat_writer.json
new file mode 100644
index 0000000000000000000000000000000000000000..9f64fa86a7cd9e03b4a51864def4bcb766056791
--- /dev/null
+++ b/setup/roles/heartbeat_writer.json
@@ -0,0 +1,18 @@
+{
+  "cluster": [
+    "manage_ilm",
+    "manage_index_templates",
+    "monitor"
+  ],
+  "indices": [
+    {
+      "names": [
+        "heartbeat-*"
+      ],
+      "privileges": [
+        "create_doc",
+        "manage"
+      ]
+    }
+  ]
+}
diff --git a/setup/roles/logstash_writer.json b/setup/roles/logstash_writer.json
new file mode 100644
index 0000000000000000000000000000000000000000..b43861fed986b58f2a215cdfcfbcbdefc520b2c7
--- /dev/null
+++ b/setup/roles/logstash_writer.json
@@ -0,0 +1,33 @@
+{
+  "cluster": [
+    "manage_index_templates",
+    "monitor",
+    "manage_ilm"
+  ],
+  "indices": [
+    {
+      "names": [
+        "logs-generic-default",
+        "logstash-*",
+        "ecs-logstash-*"
+      ],
+      "privileges": [
+        "write",
+        "create",
+        "create_index",
+        "manage",
+        "manage_ilm"
+      ]
+    },
+    {
+      "names": [
+        "logstash",
+        "ecs-logstash"
+      ],
+      "privileges": [
+        "write",
+        "manage"
+      ]
+    }
+  ]
+}
diff --git a/setup/roles/metricbeat_writer.json b/setup/roles/metricbeat_writer.json
new file mode 100644
index 0000000000000000000000000000000000000000..279308c6255bacef07f1e09b7e1d2b7cffd72109
--- /dev/null
+++ b/setup/roles/metricbeat_writer.json
@@ -0,0 +1,19 @@
+{
+  "cluster": [
+    "manage_ilm",
+    "manage_index_templates",
+    "monitor"
+  ],
+  "indices": [
+    {
+      "names": [
+        ".monitoring-*-mb",
+        "metricbeat-*"
+      ],
+      "privileges": [
+        "create_doc",
+        "manage"
+      ]
+    }
+  ]
+}