Ver código fonte

Merge branch 'master' of gitlab.2iopenservice.com:opentalent/ot_typo3

Olivier Massot 4 anos atrás
pai
commit
34885ecf8f
100 arquivos alterados com 6567 adições e 1020 exclusões
  1. 4 0
      .gitignore
  2. 36 0
      .gitlab-ci.yml
  3. 10 3
      README.md
  4. 1 0
      composer.json.txt
  5. 106 0
      doc/auth.md
  6. 183 0
      doc/be_users.md
  7. 1 0
      doc/ci.md
  8. 85 0
      doc/db.md
  9. 17 0
      doc/dependencies.md
  10. 8 0
      doc/forms.md
  11. 1 0
      doc/hooks.md
  12. BIN
      doc/images/cookie.png
  13. BIN
      doc/images/routing_blackfire_1.png
  14. 198 0
      doc/images/schema_droits_beusers.svg
  15. 1 0
      doc/images/v.svg
  16. 1 0
      doc/images/x.svg
  17. 98 0
      doc/installation.md
  18. 2 0
      doc/maintenance.md
  19. 14 0
      doc/ot_websites.md
  20. 113 0
      doc/readme.md
  21. 93 0
      doc/routing.md
  22. 1 0
      doc/social_networks.md
  23. 2 0
      doc/stats.md
  24. 2 0
      doc/templating.md
  25. 1 0
      doc/tests.md
  26. 6 3
      docker/conf/composer.json
  27. 70 0
      ot_admin/Classes/Command/AddRedirectionCommand.php
  28. 71 0
      ot_admin/Classes/Command/ClearSiteCacheCommand.php
  29. 8 10
      ot_admin/Classes/Command/CreateSiteCommand.php
  30. 20 6
      ot_admin/Classes/Command/DeleteSiteCommand.php
  31. 84 0
      ot_admin/Classes/Command/GetSiteStatusCommand.php
  32. 68 0
      ot_admin/Classes/Command/RemoveRedirectionCommand.php
  33. 113 0
      ot_admin/Classes/Command/ResetBeUserPermsCommand.php
  34. 86 0
      ot_admin/Classes/Command/ScanCommand.php
  35. 79 0
      ot_admin/Classes/Command/SetSiteDomainCommand.php
  36. 5 7
      ot_admin/Classes/Command/UndeleteSiteCommand.php
  37. 103 0
      ot_admin/Classes/Command/UpdateRoutingIndexCommand.php
  38. 69 10
      ot_admin/Classes/Command/UpdateSiteCommand.php
  39. 40 0
      ot_admin/Classes/Controller/ScanController.php
  40. 441 192
      ot_admin/Classes/Controller/SiteController.php
  41. 83 0
      ot_admin/Classes/Domain/Entity/ScanReport.php
  42. 317 0
      ot_admin/Classes/Domain/Entity/SiteInfos.php
  43. 216 0
      ot_admin/Classes/Domain/Entity/SiteStatus.php
  44. 197 15
      ot_admin/Classes/Http/ApiController.php
  45. 1 1
      ot_admin/Classes/Middleware/OtBackendUserAuthenticator.php
  46. 51 21
      ot_admin/Configuration/Backend/Routes.php
  47. 24 0
      ot_admin/Configuration/Commands.php
  48. 48 0
      ot_admin/Configuration/Services.yaml
  49. 9 9
      ot_admin/Readme.md
  50. 2 1
      ot_admin/composer.json
  51. 118 0
      ot_admin/templates/scan_report.twig
  52. 2 0
      ot_connect/Classes/Middleware/RequestHandler.php
  53. 35 84
      ot_connect/Classes/Service/OtAuthenticationService.php
  54. 2 102
      ot_connect/Readme.md
  55. 7 0
      ot_core/.gitignore
  56. 11 8
      ot_core/Classes/Cache/OtCacheManager.php
  57. 42 0
      ot_core/Classes/Controller/ActionController.php
  58. 46 8
      ot_core/Classes/Controller/SelectedSiteController.php
  59. 23 23
      ot_core/Classes/Domain/Model/Donor.php
  60. 135 135
      ot_core/Classes/Domain/Model/Event.php
  61. 56 56
      ot_core/Classes/Domain/Model/Member.php
  62. 80 83
      ot_core/Classes/Domain/Model/Organization.php
  63. 89 22
      ot_core/Classes/Domain/Repository/BaseApiRepository.php
  64. 3 3
      ot_core/Classes/Domain/Repository/DonorRepository.php
  65. 21 20
      ot_core/Classes/Domain/Repository/EventRepository.php
  66. 13 8
      ot_core/Classes/Domain/Repository/MemberRepository.php
  67. 27 12
      ot_core/Classes/Domain/Repository/OrganizationRepository.php
  68. 15 0
      ot_core/Classes/Exception/InvalidWebsiteConfigurationException.php
  69. 1 4
      ot_core/Classes/Exception/NoSiteSelected.php
  70. 15 0
      ot_core/Classes/Exception/NoSuchOrganizationException.php
  71. 15 0
      ot_core/Classes/Exception/NoSuchRecordException.php
  72. 15 0
      ot_core/Classes/Exception/NoSuchWebsiteException.php
  73. 63 0
      ot_core/Classes/Middleware/Frontend/OtSiteResolver.php
  74. 0 166
      ot_core/Classes/Page/OtPageRepository.php
  75. 33 0
      ot_core/Classes/Utility/FileUtility.php
  76. 14 0
      ot_core/Classes/Utility/RouteNormalizer.php
  77. 12 2
      ot_core/Classes/ViewHelpers/OtAbstractViewHelper.php
  78. 179 0
      ot_core/Classes/Website/OtPageRepository.php
  79. 472 0
      ot_core/Classes/Website/OtWebsiteRepository.php
  80. 18 0
      ot_core/Configuration/RequestMiddlewares.php
  81. 5 4
      ot_core/Configuration/TCA/Overrides/pages.php
  82. 5 0
      ot_core/Configuration/ot_config.yaml
  83. 6 1
      ot_core/Readme.md
  84. 13 1
      ot_core/Resources/Private/Language/locallang.xlf
  85. 40 0
      ot_core/Tests/Build/UnitTests.xml
  86. 99 0
      ot_core/Tests/Unit/Controller/SelectedSiteControllerTest.php
  87. 75 0
      ot_core/Tests/Unit/Domain/Model/DonorTest.php
  88. 430 0
      ot_core/Tests/Unit/Domain/Model/EventTest.php
  89. 155 0
      ot_core/Tests/Unit/Domain/Model/MemberTest.php
  90. 100 0
      ot_core/Tests/Unit/Domain/Model/OrganizationTest.php
  91. 75 0
      ot_core/Tests/Unit/Domain/Repository/AbstractApiRepositoryTestCase.php
  92. 51 0
      ot_core/Tests/Unit/Domain/Repository/ApiPagedCollectionTest.php
  93. 165 0
      ot_core/Tests/Unit/Domain/Repository/BaseApiRepositoryTest.php
  94. 56 0
      ot_core/Tests/Unit/Domain/Repository/DonorRepositoryTest.php
  95. 170 0
      ot_core/Tests/Unit/Domain/Repository/EventRepositoryTest.php
  96. 56 0
      ot_core/Tests/Unit/Domain/Repository/MemberRepositoryTest.php
  97. 84 0
      ot_core/Tests/Unit/Domain/Repository/OrganizationRepositoryTest.php
  98. 26 0
      ot_core/Tests/Unit/Exception/ApiRequestExceptionTest.php
  99. 198 0
      ot_core/Tests/Unit/Fixtures/ApiResponseFixtures.php
  100. 7 0
      ot_core/Tests/Unit/Fixtures/PageFixtures.php

+ 4 - 0
.gitignore

@@ -1,2 +1,6 @@
 /.project
 /.idea/
+.Build/
+composer.lock
+coverage/
+*.log

+ 36 - 0
.gitlab-ci.yml

@@ -0,0 +1,36 @@
+stages:
+  - test
+
+before_script:
+  - apt-get -yqq update
+  - apt-get -yqq install zip unzip
+  - curl -sS https://composer.github.io/installer.sig > installer.sig
+  - php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
+  - php -r "if (hash_file('SHA384', 'composer-setup.php') === file_get_contents('installer.sig')) { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
+  - php composer-setup.php
+  - php -r "unlink('composer-setup.php'); unlink('installer.sig');"
+  - pecl -q install xdebug
+  - docker-php-ext-enable xdebug
+  - echo xdebug.mode=coverage >> /usr/local/etc/php/conf.d/xdebug.ini
+
+cache:
+  paths:
+    - ./ot_core/.Build/vendor
+
+unit:otcore:
+  stage: test
+
+  script:
+    - php composer.phar --quiet --no-interaction --working-dir=./ot_core install
+    - ./ot_core/.Build/bin/phpunit -c ./ot_core/Tests/Build/UnitTests.xml --coverage-text --colors=never
+
+  artifacts:
+    paths:
+      - ./ot_core/coverage/
+    when: always
+    reports:
+      junit: ./ot_core/coverage/junit-report.xml
+
+  coverage: '/^\s*Lines:\s*\d+.\d+\%/'
+
+

+ 10 - 3
README.md

@@ -1,5 +1,12 @@
+| Extension | Status |
+| --- | --- |
+| ot_core | [![pipeline status](http://gitlab.2iopenservice.com/opentalent/ot_typo3/badges/unittests/pipeline.svg)](http://gitlab.2iopenservice.com/opentalent/ot_typo3/-/commits/unittests) [![coverage report](http://gitlab.2iopenservice.com/opentalent/ot_typo3/badges/unittests/coverage.svg)](http://gitlab.2iopenservice.com/opentalent/ot_typo3/-/commits/unittests) |
+
+
 ## Opentalent extensions for Typo3
 
+Pour plus d'informations: [consulter la documentation](/doc)
+
 ### Pré-requis:
 
 * php 7.4
@@ -11,8 +18,7 @@ Pour plus d'information sur chaque extension, consultez les readme de chacune d'
 
 ### Pour les installer
 
-1. Copier les répertoires des extensions dans le sous-répertoire `public/typo3conf/ext/` 
-du répertoire de l'application typo3
+1. Copier les répertoires des extensions dans le sous-répertoire `public/typo3conf/ext/` du répertoire de l'application typo3
 2. Ajouter les lignes suivantes au fichier `composer.json` de l'application :
 
     	"autoload": {
@@ -21,7 +27,8 @@ du répertoire de l'application typo3
                 "Opentalent\\OtConnect\\": "public/typo3conf/ext/ot_connect/Classes",
                 "Opentalent\\OtTemplating\\": "public/typo3conf/ext/ot_templating/Classes",
                 "Opentalent\\OtStats\\": "public/typo3conf/ext/ot_stats/Classes",
-                "Opentalent\\OtAdmin\\": "public/typo3conf/ext/ot_admin/Classes"
+                "Opentalent\\OtAdmin\\": "public/typo3conf/ext/ot_admin/Classes",
+                "Opentalent\\OtOptimizer\\": "public/typo3conf/ext/ot_optimizer/Classes"
     		}
     	}
 

+ 1 - 0
composer.json.txt

@@ -0,0 +1 @@
+@See docker/conf/composer.json

+ 106 - 0
doc/auth.md

@@ -0,0 +1,106 @@
+# Authentification et frontend users
+
+L'extension [OtConnect](/ot_connect) se positionne en amont des services d'authentification Typo3 et utilise l'API Opentalent.
+En somme, un utilisateur connecté sur Opentalent.fr le sera aussi sur le ou les autres sous-domaines TYPO3
+(correspondant à ses structures et à ses droits)
+
+> **Important**: ce système d'authentification ne concerne pour l'instant que les front-end users
+
+## Fonctionnement de l'authentification TYPO3
+
+### Service d'authentification
+
+Pour authentifier un utilisateur, TYPO3 exécute des services par ordre de priorité, jusqu'à ce qu'un de ces services valident l'identité
+de l'utilisateur. Si aucun des services ne valident cette authentification, celle-ci est rejetée.
+
+### Création et enregistrement d'un service d'authentification
+
+Un service d'authentification doit hériter de la classe `TYPO3\CMS\Sv\AbstractAuthenticationService`, et implémenter au moins deux méthodes:
+
+* `getUser` vérifie qu'un utilisateur portant ce nom existe en base et retourne ses informations, ou retourne false en cas d'echec.
+* `authUser` vérifie que l'utilisateur est authentifié. La méthode retourne un code indiquant le résultat:
+    * 0 signifie que l'authentification a échoué et que le process d'authentif doit s'arrêter là
+    * 100 signifie que l'authentification a échoué, mais que les services suivants peuvent essayer à leur tour d'authentifier le user
+    * 200 signifie que l'authentification a réussi
+
+De plus, le service doit être enregistré dans `ext_localconf.php` via la méthode `\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addService`.
+A cette étape, on peut lui donner des rôles (authentification backend et/ou frontend, par exemple).
+
+> **IMPORTANT** : quelle que soit la méthode d'authentification, les users backend et frontend
+>doivent avoir leurs enregistrements dans la base TYPO3 (tables `fe_users` et `be_users`)
+
+### Requêtes d'authentification
+
+Typo3 reconnait une requête d'authentification de la manière suivante :
+
+* La requête a un paramètre `logintype` dont la valeur est `login`: c'est une requête d'authentification Frontend
+* La requête a un paramètre `login_status` dont la valeur est `login`: c'est une requête d'authentification Backend
+
+Voilà les formulaires minimaux pour poster une demande d'authentification :
+
+    <-- FrontEnd -->
+    <form action="" method="POST" enctype="multipart/form-data" >
+        <input type="hidden" name="logintype" value="login" />
+        <input type="text" placeholder="Nom d'utilisateur" name="user" required="1" />
+        <input type="password" name="pass" placeholder="Mot de passe" required="1" />
+        <input type="submit" value="Se connecter" />
+    </form>
+
+    <-- BackEnd -->
+    <form action="" method="POST" enctype="multipart/form-data" >
+        <input type="hidden" name="login_status" value="login" />
+        <input type="text" placeholder="Nom d'utilisateur" name="username" required="1" />
+        <input type="password" name="password" placeholder="Mot de passe" required="1" />
+        <input type="submit" value="Se connecter" />
+    </form>
+
+> Côté Frontend, Typo3 attend deux champs dont les attributs 'name' sont 'user' et 'pass'.
+
+### Base de données
+
+Les utilisateurs **Backend** doivent avoir une ligne correspondante dans la table `be_users` de la base TYPO3.
+Ils doivent avoir a minima les champs suivants renseignés :
+
+* `username`
+* `password`: si le mot de passe n'est pas utilisé pour authentifier l'utilisateur,
+  par exemple parce qu'une API l'a déjà authentifié en amont, mettre une random string
+* `usergroup`: le user doit appartenir à un groupe existant (cf. `be_groups`), sauf s'il est admin (champs `admin` = 1)
+
+Les utilisateurs **Frontend** doivent avoir une ligne correspondante dans la table `fe_users` de la base TYPO3.
+Ils doivent avoir a minima les champs suivants renseignés :
+
+* `username`
+* `password` : si le mot de passe n'est pas utilisé pour authentifier l'utilisateur,
+  par exemple parce qu'une API l'a déjà authentifié en amont, mettre une random string
+* `usergroup` : le user doit appartenir à un groupe existant (cf. `be_groups`)
+
+### Plus d'infos
+
+> [Voir la doc officielle](https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Authentication/Index.html)
+
+
+## Fonctionnement de l'extension OtConnect
+
+Un service `OtAuthenticationService` est créé et enregistré avec les caractéristiques suivantes :
+
+* `'subtype' => 'getUserFE,authUserFE,getUserBE,authUserBE'` : le service peut récupérer les infos des users
+  et les authentifier, à la fois pour le Frontend (FE) et pour le Backend (BE)
+* `'priority' => 80`: la priorité est fixée à 80, ce qui place le service en amont des services Typo3.
+
+Enfin, la variable de configuration `FE_fetchUserIfNoSession` force l'appel à la méthode getUser à chaque affichage d'une page frontEnd si une session n'existe pas déjà.
+(sans ça, l'utilisateur doit passer par la page de login même s'il a déjà une session opentalent.fr ouverte)
+
+Voilà les différents scénarios pour un utilisateur nommé Bob.
+
+> Les cas suivants sont donnés pour le Frontend, mais ils sont identiques pour le Backend à deux différences près :
+> * Le user doit saisir son login / mdp (pas d'auto-log)
+> * Seuls les utilisateurs ayant la propriété `admin_access` à true ont accès au Backend.
+
+| Num. | Cas | Comportement |
+| --- | --- | --- |
+| 1 | Bob a une session Typo3-FE existante dans son navigateur (cf. cookie `fe_typo_user`) | Le service n'est pas appelé, Bob est déjà connecté |
+| 2 | Bob n'a pas de session Typo3 ouverte, mais il a une session ouverte dans son navigateur sur Opentalent.fr (cf. cookies `BEARER` et `SFSESSID`) | Une requête GET `/isauthenticated` est envoyée à l'API Opentalent. En cas de succès, une nouvelle requête est envoyée à l'API pour obtenir les données à jour de l'utilisateur, puis la ligne de Bob est créee ou mise à jour dans la table `fe_users` de la base Typo3 (sauf si cette mise à jour a déjà été faite dans les dernières minutes, voir const USER_UPDATE_DELAY) |
+| 3 | Bob n'a ni session Typo3-FE ni session Opentalent.fr ouverte, mais il a envoyé une requête de login valide | Ses données d'authentif sont envoyées à l'API qui lui ouvre une session. La suite se déroule comme pour le cas n°2 |
+| 4 | Bob n'a ni session Typo3-FE ni session Opentalent.fr ouverte, mais il a envoyé une requête de login invalide | Ses données d'authentif sont envoyées à l'API qui essaie de lui ouvrir une session et retourne un code d'echec. Le service, constatant qu'il s'agit tout de même d'un compte Opentalent, s'interrompt et refuse l'accès à Bob (qui pleure) |
+| 5 | Bob se connecte en utilisant un compte créé dans Typo3 ou via un autre service d'authentification (ce compte n'existe donc pas dans la base Opentalent)  | Ses données d'authentif sont envoyées à l'API qui essaie de lui ouvrir une session et retourne un code d'echec. Le service passe la main aux services Typo3 suivants par ordre de priorité |
+

+ 183 - 0
doc/be_users.md

@@ -0,0 +1,183 @@
+# Backend users
+ 
+> Voir les spécifications: https://ressources.opentalent.fr/display/SPEC/Droits+typo3
+
+## Users et groupes
+
+Le super admin est l'utilisateur 'oaos', réservé à un usage interne.
+
+Pour chaque site Typo3, il doit exister **un seul** compte admin (admin<subdomain>), et **un seul** groupe d'editeurs (edit<subdomain>), où <subdomain> est le sous-domaine du site.
+
+Les droits des beusers varient selon le produit acheté par l'organisation.
+
+
+## Droits des backend users sur les sites
+
+Ce paragraphe présente les droits des be_users sur les pages et contenus du site: création, édition, suppression...
+
+
+| Autorisations disponibles sur les pages | Code |
+| --- | --- |
+| Show | Afficher/Copier la page et le contenu | 1 |
+| Edit content | Modifier/Ajouter/Supprimer/Déplacer le contenu | 16 |
+| Edit page | Modifier la page, par ex. changer le titre de la page, etc | 2 |
+| Delete | Supprimer/déplacer la page et le contenu | 4 |
+| New | Créer de nouvelles pages sous cette page | 8 |
+
+> Les valeurs de N1 et N2 sont calculées en additionnant les valeurs suivantes: show (1), edit content (16), edit page (2), 
+> delete (4), add (8)
+
+### Licence Premium
+
+#### Admin Premium
+
+| Page | Show | Edit content | Edit page | Delete | New  | Code |
+| --- | --- | --- | --- | --- | ---  | --- |
+| Home | ![v](images/v.svg) | ![v](images/v.svg) | ![v](images/v.svg) | ![x](images/x.svg) | ![v](images/v.svg) | *27* |
+| footer/* (mentions légales, contact, plan du site) | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![v](images/v.svg) | *9* |
+| Adhérents, membres du CA, évènements, évènements des structures, structures adhérentes | ![v](images/v.svg) | ![x](images/x.svg) | ![v](images/v.svg) | ![x](images/x.svg) | ![v](images/v.svg) | *11* |
+| Autres pages | ![v](images/v.svg) | ![v](images/v.svg) | ![v](images/v.svg) | ![v](images/v.svg) | ![v](images/v.svg) | *31* |
+
+#### Editeur Premium
+
+| Page | Show | Edit content | Edit page | Delete | New  | Code |
+| --- | --- | --- | --- | --- | ---  | --- |
+| Home | ![v](images/v.svg) | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *17* |
+| footer/* (mentions légales, contact, plan du site) | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *1* |
+| Adhérents, membres du CA, évènements, évènements des structures, structures adhérentes | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *1* |
+| Autres pages | ![v](images/v.svg) | ![v](images/v.svg) | ![v](images/v.svg) | ![x](images/x.svg) | ![v](images/v.svg) | *27* |
+
+
+
+### Licence Standard
+
+#### Admin Standard
+
+| Page | Show | Edit content | Edit page | Delete | New  | Code |
+| --- | --- | --- | --- | --- | ---  | --- |
+| Home | ![v](images/v.svg) | ![v](images/v.svg) | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *19* |
+| footer/* (mentions légales, contact, plan du site) | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *1* |
+| Adhérents, membres du CA, évènements, évènements des structures, structures adhérentes | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *1* |
+| Autres pages | ![v](images/v.svg) | ![v](images/v.svg) | ![v](images/v.svg) | ![x](images/x.svg) | ![v](images/v.svg) | *27* |
+
+#### Editeur Standard
+
+| Page | Show | Edit content | Edit page | Delete | New  | Code |
+| --- | --- | --- | --- | --- | ---  | --- |
+| Home | ![v](images/v.svg) | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *17* |
+| footer/* (mentions légales, contact, plan du site) | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *1* |
+| Adhérents, membres du CA, évènements, évènements des structures, structures adhérentes | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *1* |
+| Autres pages | ![v](images/v.svg) | ![v](images/v.svg) | ![v](images/v.svg) | ![x](images/x.svg) | ![x](images/x.svg) | *19* |
+
+
+## Accès aux menus
+
+Ce paragraphe présente les menus du backend disponibles selon le type de compte.
+
+| Page | Admin Premium | Editor Premium |   | Admin Standard | Editor Standard |
+| --- | --- | --- | --- | --- | --- |
+| Web - Page | ![v](images/v.svg) | ![v](images/v.svg) |   | ![v](images/v.svg) | ![v](images/v.svg) |
+| Web - Liste | ![v](images/v.svg) | ![x](images/x.svg) |   | ![x](images/x.svg) | ![x](images/x.svg) |
+| Web - Formulaires | ![v](images/v.svg) | ![v](images/v.svg) |   | ![v](images/v.svg) | ![v](images/v.svg) |
+| Web - Corbeille | ![x](images/x.svg) | ![x](images/x.svg) |   | ![x](images/x.svg) | ![x](images/x.svg) |
+| Web - Info | ![x](images/x.svg) | ![x](images/x.svg) |   | ![x](images/x.svg) | ![x](images/x.svg) |
+| Web - Personnaliser | ![v](images/v.svg) | ![x](images/x.svg) |   | ![v](images/v.svg) | ![x](images/x.svg) |
+| Web - Statistiques | ![v](images/v.svg) | ![x](images/x.svg) |   | ![v](images/v.svg) | ![x](images/x.svg) |
+| Web - Gestion des actualités  |  ![v](images/v.svg) | ![v](images/v.svg) |   | ![v](images/v.svg) | ![v](images/v.svg) |
+| Gestionnaire de site - Redirects | ![x](images/x.svg) | ![x](images/x.svg) |   | ![x](images/x.svg) | ![x](images/x.svg) |
+| Fichier - Fichiers | ![v](images/v.svg) | ![x](images/x.svg) |   | ![v](images/v.svg) | ![x](images/x.svg) |
+| Outils Utilisateur - Configuration utilisateur | ![x](images/x.svg) | ![x](images/x.svg) |   | ![x](images/x.svg) | ![x](images/x.svg) |
+| Aide - A propos de... | ![x](images/x.svg) | ![x](images/x.svg) |   | ![x](images/x.svg) | ![x](images/x.svg) |
+| Aide -Manuel TYPO3 | ![x](images/x.svg) | ![x](images/x.svg) |   | ![x](images/x.svg) | ![x](images/x.svg) |
+
+
+## Mise en oeuvre
+
+### Principe de mise en oeuvre
+
+Pour un site 'mywebsite', on va créer un beuser admin et un groupe dédié. Au départ, l'admin est le seul membre de ce groupe, 
+mais d'autres users (les éditeurs) pourront y être ajoutés par la suite.
+
+Ce groupe permet de contrôler les droits de base sur les pages du site (créer, éditer...), droits qui sont communs à l'administrateur et aux éditeurs.  
+L'admin reçoit, en tant que propriétaire des pages du site, des droits d'accès supplémentaires (comme la suppression)
+
+Ce groupe est lui même un sous-groupe du groupe 'EditorStandard' ou 'EditorPremium', selon le produit acheté par la structure.
+Enfin, les admins sont aussi membres du groupe 'AdminStandard' ou 'AdminPremium', qui leur donnent accès aux modules réservés à l'administration (ex: 'Personnaliser')
+
+![schema](images/schema_droits_beusers.svg)
+
+
+### Nouveau client
+
+A la création d'un site dont le sous domaine serait "mysite", on procède ainsi:
+
+1. Créer un groupe 'edit_mysite'
+1. Créer un be_user 'admin_mysite'
+1. Ajouter le be_user 'admin_mysite' au groupe 'edit_mysite'
+1. Ajouter le be_user 'admin_mysite' au groupe 'AdminStandard' ou 'AdminPremium' (selon le produit)
+1. Ajouter le groupe 'edit_mysite' au groupe WriterBasic ou WriterFull, selon la nature du produit
+
+
+
+On attribue ensuite les droits en mettant à jour les champs suivants:
+
+| perms_userid | perms_groupid | perms_user | perms_group | perms_everybody |
+| --- | --- | --- | --- | --- |
+| uid de admin_mysite | uid de edit_mysite | N1* | N2* | 0 |
+
+
+> Toutes ces opérations sont automatisées et peuvent être exécutées via la commande:
+>
+>    `php /var/www/typo3/vendor/bin/typo3 ot:site:reset-perms --create <organization-id>`
+
+
+### La structure change de produit
+
+Pour mettre à jour les droits des be_users , par exemple après un changement de produit:
+
+1. On met à jour les droits sur les pages de 'admin_mysite' et 'edit_mysite' (cf paragraphe précédent) avec la commande ot:site:reset-perms
+1. On retire le groupe 'edit_mysite' de son groupe actuel
+1. On ajoute le groupe 'edit_mysite' au groupe Editor Standard ou Editor Premium, selon la nature du produit
+
+> Toutes ces opérations sont automatisées et peuvent être exécutées via la commande:
+>
+>    `php /var/www/typo3/vendor/bin/typo3 ot:site:reset-perms --create <organization-id>`
+
+
+### Si on veut changer les droits associés aux produits
+
+#### Droits issus des groupes de base
+
+Si les droits à modifier sont hérités d'un des groupes de base (Admin Premium, Editor Premium, Admin Standard, 
+Editor Standard, alors il suffit de modifier le ou les groupes concernés.
+
+
+#### Droits d'accès aux pages
+
+Pour modifier les droits sur les pages, on fera d'abord évoluer la commande la commande ot:site:reset-perms, puis on l'exécute sur tous les sites avec l'option '–all'
+
+    php /var/www/typo3/vendor/bin/typo3 ot:site:reset-perms --all --create
+
+> (L'opération peut prendre quelques minutes)
+
+### Pour ajouter un utilisateur de type éditeur
+
+On créé le be_user correspondant, et on l'affecte au groupe edit_mysite.
+
+
+###  L'administrateur ou un des éditeurs a été modifié dans le logiciel
+
+On déclenche une update des données des be_users via la commande
+
+    php /var/www/typo3/vendor/bin/typo3 ot:site:reset-perms <organization-id>
+
+Ou en effectuant une requête HTTP à l'adresse:
+
+    GET https://preprod.opentalent.fr/typo3/index.php?route=/otadmin/site/reset-perms&organization-id=<organization-id>
+
+ 
+
+ 
+
+ 
+

+ 1 - 0
doc/ci.md

@@ -0,0 +1 @@
+# Intégration et déploiement continu

+ 85 - 0
doc/db.md

@@ -0,0 +1,85 @@
+# Comprendre la DB Typo3
+
+Voici les principales tables sur lesquelles on est amené à intervenir. Toutes les autres tables sont soient
+rendues inutiles par le fonctionnement spécifiques de l'instance Opentalent (comme `sys_domain`), ou ne nécessitent
+quasiment jamais d'intervention manuelles.
+
+
+## Règles générales
+
+Toutes les tables ont pour clé primaire un champs `uid`.
+
+La majorité des tables définissent un champ `pid`, pour "parent id". C'est l'uid de l'objet parent, par exemple
+la page qui le contient pour contenu.
+
+La majorité des tables définissent un champ `deleted`. Par défaut, le QueryBuilder typo3 ignorera de lui-même 
+les champs dont ce champ est à 1.
+
+
+## La table `pages`
+
+La table `pages` contient un enregistrement pour chaque page d'un site.
+Un champs spécifique Opentalent (`ot_website_uid`) permet de faire le lien entre une page et son site.
+
+Chaque site est constitué d'une page racine (`is_siteroot=1`). Ensuite l'arborescence des pages du site est 
+définie par l'intermédiaire du champs pid, le pid d'une page étant l'uid de sa page parente.
+
+Le champs `dokType` définit le type d'une page (standard, raccourci, dossier...). Une page standard a pour dokType 1. 
+
+Le champs `slug` est responsable du routing à l'intérieur du site.
+
+
+## La table `ot_websites`
+
+> Voir [le chapitre dédié](ot_websites.md)
+
+## La table `tt_content`
+
+La table `tt_content` contient un enregistrement pour chaque contenu d'une page.
+
+Le champs `pid` correspond à l'uid de la page qui le contient.
+
+Le type de contenu est défini par le champs `CType`
+
+Le corps du contenu est défini par le champs `bodytext`
+
+
+## Les tables `fe_users` et `fe_groups`
+
+La table `fe_users` contient un enregistrement pour chaque frontend user.
+
+Ces enregistrements sont générés automatiquement via l'extension [OtConnect](/ot_connect).
+
+> Plus d'info [ici](auth.md)
+
+Chaque fe_user peut être affecté à un ou plusieurs fe_group par l'intermédiaire du champs `usergroup` (plusieurs valeurs
+possibles en séparant les uids avec une virgule)
+
+Les groupes sont lié à un site par l'intermédiaire du champs `pid`, qui contient l'uid de la page racine du site
+auquel le groupe est lié.
+
+## La table `be_users` et `be_groups`
+
+La table `be_users` contient un enregistrement pour chaque backend user.
+
+Chaque be_user peut être affecté à un ou plusieurs be_group par l'intermédiaire du champs `usergroup` (plusieurs valeurs
+possibles en séparant les uids avec une virgule)
+
+> Plus d'infos [ici](be_users.md)
+
+## Les tables `sys_file` et `sys_file_reference`
+
+
+Chaque fichier uploadé depuis le backend a un enregistrement correspondant dans la table `sys_file`.
+
+C'est ensuite la table `sys_file_reference` qui fait le lien entre cette table et la table étrangère qui référence ce 
+fichier (pages, tt_content...)
+
+La table ciblée et la clé étrangère sont indiquées par les champs `tablenames` et `uid_foreign` de la table `sys_file_reference`, 
+tandis que l'id du fichier dans `sys_file` est contenu dans le champs `uid_local`
+
+
+## Les tables `sys_log` et `ot_log`
+
+Les tables `sys_log` et `ot_log` contiennent les logs typo3 pour la première, et spécifiques aux extensions 
+Opentalent pour la seconde.

+ 17 - 0
doc/dependencies.md

@@ -0,0 +1,17 @@
+# Extensions tierces et dépendances
+
+L'instance Typo3 installée dépend essentiellement des extensions et librairies suivantes:
+
+## Extensions Typo3
+
+### Fluid
+### VHS
+### Flux
+### News
+
+## Autres dépendances
+
+### SCSS
+### JQuery
+### Openstreetmap
+### Matomo

+ 8 - 0
doc/forms.md

@@ -0,0 +1,8 @@
+# Formulaires
+
+La gestion des formulaires est assurée par l'intermédiaire de l'extension native 'Forms'.
+
+Tous les sites ont un accès en lecture seule au formulaire Contact, qui est défini au sein de l'extension ot_templating.
+
+Chaque site possède ensuite un répertoire dédié: `/var/www/typo3/public/fileadmin/form_definitions/<organization-id>/` 
+dans lequel les définitions des formulaires (fichiers en .form.yaml) créés par le beuser sont enregistrés.

+ 1 - 0
doc/hooks.md

@@ -0,0 +1 @@
+# Déclenchement des hooks de mise à jour depuis le logiciel

BIN
doc/images/cookie.png


BIN
doc/images/routing_blackfire_1.png


+ 198 - 0
doc/images/schema_droits_beusers.svg

@@ -0,0 +1,198 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.2" width="297mm" height="210mm" viewBox="0 0 29700 21000" preserveAspectRatio="xMidYMid" fill-rule="evenodd" stroke-width="28.222" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" xmlns:ooo="http://xml.openoffice.org/svg/export" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:presentation="http://sun.com/xmlns/staroffice/presentation" xmlns:smil="http://www.w3.org/2001/SMIL20/" xmlns:anim="urn:oasis:names:tc:opendocument:xmlns:animation:1.0" xml:space="preserve">
+ <defs class="ClipPathGroup">
+  <clipPath id="presentation_clip_path" clipPathUnits="userSpaceOnUse">
+   <rect x="0" y="0" width="29700" height="21000"/>
+  </clipPath>
+  <clipPath id="presentation_clip_path_shrink" clipPathUnits="userSpaceOnUse">
+   <rect x="29" y="21" width="29641" height="20958"/>
+  </clipPath>
+ </defs>
+ <defs>
+  <font id="EmbeddedFont_1" horiz-adv-x="2048">
+   <font-face font-family="Liberation Sans embedded" units-per-em="2048" font-weight="normal" font-style="normal" ascent="1852" descent="423"/>
+   <missing-glyph horiz-adv-x="2048" d="M 0,0 L 2047,0 2047,2047 0,2047 0,0 Z"/>
+   <glyph unicode="’" horiz-adv-x="239" d="M 328,1264 C 328,1194 322,1135 309,1088 296,1040 277,995 250,952 L 127,952 C 190,1043 221,1129 221,1212 L 133,1212 133,1409 328,1409 328,1264 Z"/>
+   <glyph unicode="é" horiz-adv-x="980" d="M 276,503 C 276,379 302,283 353,216 404,149 479,115 578,115 656,115 719,131 766,162 813,193 844,233 861,281 L 1019,236 C 954,65 807,-20 578,-20 418,-20 296,28 213,123 129,218 87,360 87,548 87,727 129,864 213,959 296,1054 416,1102 571,1102 889,1102 1048,910 1048,527 L 1048,503 276,503 Z M 862,641 C 852,755 823,838 775,891 727,943 658,969 568,969 481,969 412,940 361,882 310,823 282,743 278,641 L 862,641 Z M 440,1201 L 440,1221 657,1508 864,1508 864,1479 534,1201 440,1201 Z"/>
+   <glyph unicode="è" horiz-adv-x="980" d="M 276,503 C 276,379 302,283 353,216 404,149 479,115 578,115 656,115 719,131 766,162 813,193 844,233 861,281 L 1019,236 C 954,65 807,-20 578,-20 418,-20 296,28 213,123 129,218 87,360 87,548 87,727 129,864 213,959 296,1054 416,1102 571,1102 889,1102 1048,910 1048,527 L 1048,503 276,503 Z M 862,641 C 852,755 823,838 775,891 727,943 658,969 568,969 481,969 412,940 361,882 310,823 282,743 278,641 L 862,641 Z M 657,1201 L 327,1479 327,1508 534,1508 751,1221 751,1201 657,1201 Z"/>
+   <glyph unicode="y" horiz-adv-x="1033" d="M 191,-425 C 142,-425 100,-421 67,-414 L 67,-279 C 92,-283 120,-285 151,-285 263,-285 352,-203 417,-38 L 434,5 5,1082 197,1082 425,484 C 428,475 432,464 437,451 442,438 457,394 482,320 507,246 521,205 523,196 L 593,393 830,1082 1020,1082 604,0 C 559,-115 518,-201 479,-258 440,-314 398,-356 351,-384 304,-411 250,-425 191,-425 Z"/>
+   <glyph unicode="x" horiz-adv-x="1006" d="M 801,0 L 510,444 217,0 23,0 408,556 41,1082 240,1082 510,661 778,1082 979,1082 612,558 1002,0 801,0 Z"/>
+   <glyph unicode="u" horiz-adv-x="874" d="M 314,1082 L 314,396 C 314,325 321,269 335,230 349,191 371,162 402,145 433,128 478,119 537,119 624,119 692,149 742,208 792,267 817,350 817,455 L 817,1082 997,1082 997,231 C 997,105 999,28 1003,0 L 833,0 C 832,3 832,12 831,27 830,42 830,59 829,78 828,97 826,132 825,185 L 822,185 C 781,110 733,58 679,27 624,-4 557,-20 476,-20 357,-20 271,10 216,69 161,128 133,225 133,361 L 133,1082 314,1082 Z"/>
+   <glyph unicode="t" horiz-adv-x="531" d="M 554,8 C 495,-8 434,-16 372,-16 228,-16 156,66 156,229 L 156,951 31,951 31,1082 163,1082 216,1324 336,1324 336,1082 536,1082 536,951 336,951 336,268 C 336,216 345,180 362,159 379,138 408,127 450,127 474,127 509,132 554,141 L 554,8 Z"/>
+   <glyph unicode="s" horiz-adv-x="901" d="M 950,299 C 950,197 912,118 835,63 758,8 650,-20 511,-20 376,-20 273,2 200,47 127,91 79,160 57,254 L 216,285 C 231,227 263,185 311,158 359,131 426,117 511,117 602,117 669,131 712,159 754,187 775,229 775,285 775,328 760,362 731,389 702,416 654,438 589,455 L 460,489 C 357,516 283,542 240,568 196,593 162,624 137,661 112,698 100,743 100,796 100,895 135,970 206,1022 276,1073 378,1099 513,1099 632,1099 727,1078 798,1036 868,994 912,927 931,834 L 769,814 C 759,862 732,899 689,925 645,950 586,963 513,963 432,963 372,951 333,926 294,901 275,864 275,814 275,783 283,758 299,738 315,718 339,701 370,687 401,673 467,654 568,629 663,605 732,583 774,563 816,542 849,520 874,495 898,470 917,442 930,410 943,377 950,340 950,299 Z"/>
+   <glyph unicode="r" horiz-adv-x="530" d="M 142,0 L 142,830 C 142,906 140,990 136,1082 L 306,1082 C 311,959 314,886 314,861 L 318,861 C 347,954 380,1017 417,1051 454,1085 507,1102 575,1102 599,1102 623,1099 648,1092 L 648,927 C 624,934 592,937 552,937 477,937 420,905 381,841 342,776 322,684 322,564 L 322,0 142,0 Z"/>
+   <glyph unicode="q" horiz-adv-x="927" d="M 484,-20 C 347,-20 246,26 182,119 118,212 86,351 86,536 86,913 219,1102 484,1102 566,1102 634,1088 687,1059 740,1030 785,981 821,914 L 823,914 C 823,934 824,969 827,1018 830,1067 832,1093 835,1096 L 1008,1096 C 1003,1057 1001,958 1001,801 L 1001,-425 821,-425 821,14 825,178 823,178 C 787,107 743,56 690,26 637,-5 569,-20 484,-20 Z M 821,554 C 821,695 798,799 752,867 706,935 633,969 532,969 441,969 375,935 335,867 295,799 275,691 275,542 275,391 295,282 336,217 376,152 441,119 530,119 632,119 706,155 752,228 798,301 821,409 821,554 Z"/>
+   <glyph unicode="p" horiz-adv-x="953" d="M 1053,546 C 1053,169 920,-20 655,-20 488,-20 376,43 319,168 L 314,168 C 317,163 318,106 318,-2 L 318,-425 138,-425 138,861 C 138,972 136,1046 132,1082 L 306,1082 C 307,1079 308,1070 309,1054 310,1037 312,1012 314,978 315,944 316,921 316,908 L 320,908 C 352,975 394,1024 447,1055 500,1086 569,1101 655,1101 788,1101 888,1056 954,967 1020,878 1053,737 1053,546 Z M 864,542 C 864,693 844,800 803,865 762,930 698,962 609,962 538,962 482,947 442,917 401,887 371,840 350,777 329,713 318,630 318,528 318,386 341,281 386,214 431,147 505,113 607,113 696,113 762,146 803,212 844,277 864,387 864,542 Z"/>
+   <glyph unicode="o" horiz-adv-x="980" d="M 1053,542 C 1053,353 1011,212 928,119 845,26 724,-20 565,-20 407,-20 288,28 207,125 126,221 86,360 86,542 86,915 248,1102 571,1102 736,1102 858,1057 936,966 1014,875 1053,733 1053,542 Z M 864,542 C 864,691 842,800 798,868 753,935 679,969 574,969 469,969 393,935 346,866 299,797 275,689 275,542 275,399 298,292 345,221 391,149 464,113 563,113 671,113 748,148 795,217 841,286 864,395 864,542 Z"/>
+   <glyph unicode="n" horiz-adv-x="874" d="M 825,0 L 825,686 C 825,757 818,813 804,852 790,891 768,920 737,937 706,954 661,963 602,963 515,963 447,933 397,874 347,815 322,732 322,627 L 322,0 142,0 142,851 C 142,977 140,1054 136,1082 L 306,1082 C 307,1079 307,1070 308,1055 309,1040 310,1024 311,1005 312,986 313,950 314,897 L 317,897 C 358,972 406,1025 461,1056 515,1087 582,1102 663,1102 782,1102 869,1073 924,1014 979,955 1006,857 1006,721 L 1006,0 825,0 Z"/>
+   <glyph unicode="m" horiz-adv-x="1457" d="M 768,0 L 768,686 C 768,791 754,863 725,903 696,943 645,963 570,963 493,963 433,934 388,875 343,816 321,734 321,627 L 321,0 142,0 142,851 C 142,977 140,1054 136,1082 L 306,1082 C 307,1079 307,1070 308,1055 309,1040 310,1024 311,1005 312,986 313,950 314,897 L 317,897 C 356,974 400,1027 450,1057 500,1087 561,1102 633,1102 715,1102 780,1086 828,1053 875,1020 908,968 927,897 L 930,897 C 967,970 1013,1022 1066,1054 1119,1086 1183,1102 1258,1102 1367,1102 1447,1072 1497,1013 1546,954 1571,856 1571,721 L 1571,0 1393,0 1393,686 C 1393,791 1379,863 1350,903 1321,943 1270,963 1195,963 1116,963 1055,934 1012,876 968,817 946,734 946,627 L 946,0 768,0 Z"/>
+   <glyph unicode="l" horiz-adv-x="187" d="M 138,0 L 138,1484 318,1484 318,0 138,0 Z"/>
+   <glyph unicode="i" horiz-adv-x="187" d="M 137,1312 L 137,1484 317,1484 317,1312 137,1312 Z M 137,0 L 137,1082 317,1082 317,0 137,0 Z"/>
+   <glyph unicode="g" horiz-adv-x="927" d="M 548,-425 C 430,-425 336,-402 266,-356 196,-309 151,-243 131,-158 L 312,-132 C 324,-182 351,-220 392,-248 433,-274 486,-288 553,-288 732,-288 822,-183 822,27 L 822,201 820,201 C 786,132 739,80 680,45 621,10 551,-8 472,-8 339,-8 242,36 180,124 117,212 86,350 86,539 86,730 120,872 187,963 254,1054 355,1099 492,1099 569,1099 635,1082 692,1047 748,1012 791,962 822,897 L 824,897 C 824,917 825,952 828,1001 831,1050 833,1077 836,1082 L 1007,1082 C 1003,1046 1001,971 1001,858 L 1001,31 C 1001,-273 850,-425 548,-425 Z M 822,541 C 822,629 810,705 786,769 762,832 728,881 685,915 641,948 591,965 536,965 444,965 377,932 335,865 293,798 272,690 272,541 272,393 292,287 331,222 370,157 438,125 533,125 590,125 640,142 684,175 728,208 762,256 786,319 810,381 822,455 822,541 Z"/>
+   <glyph unicode="e" horiz-adv-x="980" d="M 276,503 C 276,379 302,283 353,216 404,149 479,115 578,115 656,115 719,131 766,162 813,193 844,233 861,281 L 1019,236 C 954,65 807,-20 578,-20 418,-20 296,28 213,123 129,218 87,360 87,548 87,727 129,864 213,959 296,1054 416,1102 571,1102 889,1102 1048,910 1048,527 L 1048,503 276,503 Z M 862,641 C 852,755 823,838 775,891 727,943 658,969 568,969 481,969 412,940 361,882 310,823 282,743 278,641 L 862,641 Z"/>
+   <glyph unicode="d" horiz-adv-x="927" d="M 821,174 C 788,105 744,55 689,25 634,-5 565,-20 484,-20 347,-20 247,26 183,118 118,210 86,349 86,536 86,913 219,1102 484,1102 566,1102 634,1087 689,1057 744,1027 788,979 821,914 L 823,914 821,1035 821,1484 1001,1484 1001,223 C 1001,110 1003,36 1007,0 L 835,0 C 833,11 831,35 829,74 826,113 825,146 825,174 L 821,174 Z M 275,542 C 275,391 295,282 335,217 375,152 440,119 530,119 632,119 706,154 752,225 798,296 821,405 821,554 821,697 798,802 752,869 706,936 633,969 532,969 441,969 376,936 336,869 295,802 275,693 275,542 Z"/>
+   <glyph unicode="c" horiz-adv-x="901" d="M 275,546 C 275,402 298,295 343,226 388,157 457,122 548,122 612,122 666,139 709,174 752,209 778,262 788,334 L 970,322 C 956,218 912,135 837,73 762,11 668,-20 553,-20 402,-20 286,28 207,124 127,219 87,359 87,542 87,724 127,863 207,959 287,1054 402,1102 551,1102 662,1102 754,1073 827,1016 900,959 945,880 964,779 L 779,765 C 770,825 746,873 708,908 670,943 616,961 546,961 451,961 382,929 339,866 296,803 275,696 275,546 Z"/>
+   <glyph unicode="a" horiz-adv-x="1060" d="M 414,-20 C 305,-20 224,9 169,66 114,123 87,202 87,302 87,414 124,500 198,560 271,620 390,652 554,656 L 797,660 797,719 C 797,807 778,870 741,908 704,946 645,965 565,965 484,965 426,951 389,924 352,897 330,853 323,793 L 135,810 C 166,1005 310,1102 569,1102 705,1102 807,1071 876,1009 945,946 979,856 979,738 L 979,272 C 979,219 986,179 1000,152 1014,125 1041,111 1080,111 1097,111 1117,113 1139,118 L 1139,6 C 1094,-5 1047,-10 1000,-10 933,-10 885,8 855,43 824,78 807,132 803,207 L 797,207 C 751,124 698,66 637,32 576,-3 501,-20 414,-20 Z M 455,115 C 521,115 580,130 631,160 682,190 723,231 753,284 782,336 797,390 797,445 L 797,534 600,530 C 515,529 451,520 408,504 364,488 330,463 307,430 284,397 272,353 272,299 272,240 288,195 320,163 351,131 396,115 455,115 Z"/>
+   <glyph unicode="_" horiz-adv-x="1218" d="M -31,-407 L -31,-277 1162,-277 1162,-407 -31,-407 Z"/>
+   <glyph unicode="S" horiz-adv-x="1192" d="M 1272,389 C 1272,259 1221,158 1120,87 1018,16 875,-20 690,-20 347,-20 148,99 93,338 L 278,375 C 299,290 345,228 414,189 483,149 578,129 697,129 820,129 916,150 983,193 1050,235 1083,297 1083,379 1083,425 1073,462 1052,491 1031,520 1001,543 963,562 925,581 880,596 827,609 774,622 716,635 652,650 541,675 456,699 399,724 341,749 295,776 262,807 229,837 203,872 186,913 168,954 159,1000 159,1053 159,1174 205,1267 298,1332 390,1397 522,1430 694,1430 854,1430 976,1406 1061,1357 1146,1308 1205,1224 1239,1106 L 1051,1073 C 1030,1148 991,1202 933,1236 875,1269 795,1286 692,1286 579,1286 493,1267 434,1230 375,1193 345,1137 345,1063 345,1020 357,984 380,956 403,927 436,903 479,884 522,864 609,840 738,811 781,801 825,791 868,781 911,770 952,758 991,744 1030,729 1067,712 1102,693 1136,674 1166,650 1191,622 1216,594 1236,561 1251,523 1265,485 1272,440 1272,389 Z"/>
+   <glyph unicode="P" horiz-adv-x="1112" d="M 1258,985 C 1258,852 1215,746 1128,667 1041,588 922,549 773,549 L 359,549 359,0 168,0 168,1409 761,1409 C 919,1409 1041,1372 1128,1298 1215,1224 1258,1120 1258,985 Z M 1066,983 C 1066,1165 957,1256 738,1256 L 359,1256 359,700 746,700 C 959,700 1066,794 1066,983 Z"/>
+   <glyph unicode="E" horiz-adv-x="1138" d="M 168,0 L 168,1409 1237,1409 1237,1253 359,1253 359,801 1177,801 1177,647 359,647 359,156 1278,156 1278,0 168,0 Z"/>
+   <glyph unicode="D" horiz-adv-x="1218" d="M 1381,719 C 1381,574 1353,447 1296,338 1239,229 1159,145 1055,87 951,29 831,0 695,0 L 168,0 168,1409 634,1409 C 873,1409 1057,1349 1187,1230 1316,1110 1381,940 1381,719 Z M 1189,719 C 1189,894 1141,1027 1046,1119 950,1210 811,1256 630,1256 L 359,1256 359,153 673,153 C 776,153 867,176 946,221 1024,266 1084,332 1126,417 1168,502 1189,603 1189,719 Z"/>
+   <glyph unicode="A" horiz-adv-x="1377" d="M 1167,0 L 1006,412 364,412 202,0 4,0 579,1409 796,1409 1362,0 1167,0 Z M 685,1265 L 676,1237 C 659,1182 635,1111 602,1024 L 422,561 949,561 768,1026 C 749,1072 731,1124 712,1182 L 685,1265 Z"/>
+   <glyph unicode="1" horiz-adv-x="927" d="M 156,0 L 156,153 515,153 515,1237 197,1010 197,1180 530,1409 696,1409 696,153 1039,153 1039,0 156,0 Z"/>
+   <glyph unicode="/" horiz-adv-x="583" d="M 0,-20 L 411,1484 569,1484 162,-20 0,-20 Z"/>
+   <glyph unicode=" " horiz-adv-x="556"/>
+  </font>
+ </defs>
+ <defs class="TextShapeIndex">
+  <g ooo:slide="id1" ooo:id-list="id3 id4 id5 id6 id7 id8 id9 id10 id11 id12 id13 id14 id15 id16"/>
+ </defs>
+ <defs class="EmbeddedBulletChars">
+  <g id="bullet-char-template-57356" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 580,1141 L 1163,571 580,0 -4,571 580,1141 Z"/>
+  </g>
+  <g id="bullet-char-template-57354" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 8,1128 L 1137,1128 1137,0 8,0 8,1128 Z"/>
+  </g>
+  <g id="bullet-char-template-10146" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 174,0 L 602,739 174,1481 1456,739 174,0 Z M 1358,739 L 309,1346 659,739 1358,739 Z"/>
+  </g>
+  <g id="bullet-char-template-10132" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 2015,739 L 1276,0 717,0 1260,543 174,543 174,936 1260,936 717,1481 1274,1481 2015,739 Z"/>
+  </g>
+  <g id="bullet-char-template-10007" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 0,-2 C -7,14 -16,27 -25,37 L 356,567 C 262,823 215,952 215,954 215,979 228,992 255,992 264,992 276,990 289,987 310,991 331,999 354,1012 L 381,999 492,748 772,1049 836,1024 860,1049 C 881,1039 901,1025 922,1006 886,937 835,863 770,784 769,783 710,716 594,584 L 774,223 C 774,196 753,168 711,139 L 727,119 C 717,90 699,76 672,76 641,76 570,178 457,381 L 164,-76 C 142,-110 111,-127 72,-127 30,-127 9,-110 8,-76 1,-67 -2,-52 -2,-32 -2,-23 -1,-13 0,-2 Z"/>
+  </g>
+  <g id="bullet-char-template-10004" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 285,-33 C 182,-33 111,30 74,156 52,228 41,333 41,471 41,549 55,616 82,672 116,743 169,778 240,778 293,778 328,747 346,684 L 369,508 C 377,444 397,411 428,410 L 1163,1116 C 1174,1127 1196,1133 1229,1133 1271,1133 1292,1118 1292,1087 L 1292,965 C 1292,929 1282,901 1262,881 L 442,47 C 390,-6 338,-33 285,-33 Z"/>
+  </g>
+  <g id="bullet-char-template-9679" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 813,0 C 632,0 489,54 383,161 276,268 223,411 223,592 223,773 276,916 383,1023 489,1130 632,1184 813,1184 992,1184 1136,1130 1245,1023 1353,916 1407,772 1407,592 1407,412 1353,268 1245,161 1136,54 992,0 813,0 Z"/>
+  </g>
+  <g id="bullet-char-template-8226" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 346,457 C 273,457 209,483 155,535 101,586 74,649 74,723 74,796 101,859 155,911 209,963 273,989 346,989 419,989 480,963 531,910 582,859 608,796 608,723 608,648 583,586 532,535 482,483 420,457 346,457 Z"/>
+  </g>
+  <g id="bullet-char-template-8211" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M -4,459 L 1135,459 1135,606 -4,606 -4,459 Z"/>
+  </g>
+  <g id="bullet-char-template-61548" transform="scale(0.00048828125,-0.00048828125)">
+   <path d="M 173,740 C 173,903 231,1043 346,1159 462,1274 601,1332 765,1332 928,1332 1067,1274 1183,1159 1299,1043 1357,903 1357,740 1357,577 1299,437 1183,322 1067,206 928,148 765,148 601,148 462,206 346,322 231,437 173,577 173,740 Z"/>
+  </g>
+ </defs>
+ <g>
+  <g id="id2" class="Master_Slide">
+   <g id="bg-id2" class="Background"/>
+   <g id="bo-id2" class="BackgroundObjects"/>
+  </g>
+ </g>
+ <g class="SlideGroup">
+  <g>
+   <g id="container-id1">
+    <g id="id1" class="Slide" clip-path="url(#presentation_clip_path)">
+     <g class="Page">
+      <g class="com.sun.star.drawing.CustomShape">
+       <g id="id3">
+        <rect class="BoundingBox" stroke="none" fill="none" x="1999" y="2099" width="11803" height="1103"/>
+        <path fill="rgb(114,159,207)" stroke="none" d="M 7900,3200 L 2000,3200 2000,2100 13800,2100 13800,3200 7900,3200 Z"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 7900,3200 L 2000,3200 2000,2100 13800,2100 13800,3200 7900,3200 Z"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="635px" font-weight="400"><tspan class="TextPosition" x="3426" y="2871"><tspan fill="rgb(0,0,0)" stroke="none">EditorStandard / EditorPremium</tspan></tspan></tspan></text>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.CustomShape">
+       <g id="id4">
+        <rect class="BoundingBox" stroke="none" fill="none" x="16999" y="2099" width="10103" height="1103"/>
+        <path fill="rgb(114,159,207)" stroke="none" d="M 22050,3200 L 17000,3200 17000,2100 27100,2100 27100,3200 22050,3200 Z"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 22050,3200 L 17000,3200 17000,2100 27100,2100 27100,3200 22050,3200 Z"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="635px" font-weight="400"><tspan class="TextPosition" x="17455" y="2871"><tspan fill="rgb(0,0,0)" stroke="none">AdminStandard / AdminPremium</tspan></tspan></tspan></text>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.CustomShape">
+       <g id="id5">
+        <rect class="BoundingBox" stroke="none" fill="none" x="15399" y="11099" width="4704" height="2204"/>
+        <path fill="rgb(255,128,0)" stroke="none" d="M 17750,11100 L 20101,12200 17750,13301 15400,12200 17750,11100 17750,11100 Z"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 17750,11100 L 20101,12200 17750,13301 15400,12200 17750,11100 17750,11100 Z"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="635px" font-weight="400"><tspan class="TextPosition" x="16219" y="12421"><tspan fill="rgb(0,0,0)" stroke="none">admin_site</tspan></tspan></tspan></text>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.CustomShape">
+       <g id="id6">
+        <rect class="BoundingBox" stroke="none" fill="none" x="1799" y="11399" width="4704" height="2204"/>
+        <path fill="rgb(255,128,0)" stroke="none" d="M 4150,11400 L 6501,12500 4150,13601 1800,12500 4150,11400 4150,11400 Z"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 4150,11400 L 6501,12500 4150,13601 1800,12500 4150,11400 4150,11400 Z"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="635px" font-weight="400"><tspan class="TextPosition" x="3182" y="12721"><tspan fill="rgb(0,0,0)" stroke="none">editor1</tspan></tspan></tspan></text>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.CustomShape">
+       <g id="id7">
+        <rect class="BoundingBox" stroke="none" fill="none" x="1799" y="6099" width="14303" height="1103"/>
+        <path fill="rgb(114,159,207)" stroke="none" d="M 8950,7200 L 1800,7200 1800,6100 16100,6100 16100,7200 8950,7200 Z"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 8950,7200 L 1800,7200 1800,6100 16100,6100 16100,7200 8950,7200 Z"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="635px" font-weight="400"><tspan class="TextPosition" x="7348" y="6871"><tspan fill="rgb(0,0,0)" stroke="none">edit_mysite</tspan></tspan></tspan></text>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.ConnectorShape">
+       <g id="id8">
+        <rect class="BoundingBox" stroke="none" fill="none" x="7899" y="3199" width="1202" height="2902"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 7900,3200 L 7900,3701 8950,5599 8950,5670"/>
+        <path fill="rgb(52,101,164)" stroke="none" d="M 8950,6100 L 9100,5650 8800,5650 8950,6100 Z"/>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.ConnectorShape">
+       <g id="id9">
+        <rect class="BoundingBox" stroke="none" fill="none" x="4000" y="7199" width="4952" height="4202"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 8950,7200 L 8950,9500 4150,10899 4150,10970"/>
+        <path fill="rgb(52,101,164)" stroke="none" d="M 4150,11400 L 4300,10950 4000,10950 4150,11400 Z"/>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.ConnectorShape">
+       <g id="id10">
+        <rect class="BoundingBox" stroke="none" fill="none" x="8949" y="7199" width="8952" height="3902"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 8950,7200 L 8950,9500 17750,10599 17750,10670"/>
+        <path fill="rgb(52,101,164)" stroke="none" d="M 17750,11100 L 17900,10650 17600,10650 17750,11100 Z"/>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.ConnectorShape">
+       <g id="id11">
+        <rect class="BoundingBox" stroke="none" fill="none" x="17600" y="3199" width="4452" height="7902"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 22050,3200 L 22050,3701 17750,10599 17750,10670"/>
+        <path fill="rgb(52,101,164)" stroke="none" d="M 17750,11100 L 17900,10650 17600,10650 17750,11100 Z"/>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.TextShape">
+       <g id="id12">
+        <rect class="BoundingBox" stroke="none" fill="none" x="4200" y="3900" width="4601" height="1362"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="494px" font-weight="400"><tspan class="TextPosition" x="4450" y="4474"><tspan fill="rgb(0,0,0)" stroke="none">Accès aux </tspan></tspan><tspan class="TextPosition" x="4450" y="5029"><tspan fill="rgb(0,0,0)" stroke="none">modules d’édition</tspan></tspan></tspan></text>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.TextShape">
+       <g id="id13">
+        <rect class="BoundingBox" stroke="none" fill="none" x="21000" y="5384" width="4601" height="1917"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="494px" font-weight="400"><tspan class="TextPosition" x="21250" y="5958"><tspan fill="rgb(0,0,0)" stroke="none">Accès aux </tspan></tspan><tspan class="TextPosition" x="21250" y="6513"><tspan fill="rgb(0,0,0)" stroke="none">modules </tspan></tspan><tspan class="TextPosition" x="21250" y="7068"><tspan fill="rgb(0,0,0)" stroke="none">d’administration</tspan></tspan></tspan></text>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.TextShape">
+       <g id="id14">
+        <rect class="BoundingBox" stroke="none" fill="none" x="5000" y="7700" width="4601" height="1362"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="494px" font-weight="400"><tspan class="TextPosition" x="5250" y="8274"><tspan fill="rgb(0,0,0)" stroke="none">Droits d’édition </tspan></tspan><tspan class="TextPosition" x="5250" y="8829"><tspan fill="rgb(0,0,0)" stroke="none">sur les pages</tspan></tspan></tspan></text>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.ConnectorShape">
+       <g id="id15">
+        <rect class="BoundingBox" stroke="none" fill="none" x="20100" y="10929" width="2753" height="1422"/>
+        <path fill="none" stroke="rgb(52,101,164)" d="M 22851,10930 L 20602,12200 20530,12200"/>
+        <path fill="rgb(52,101,164)" stroke="none" d="M 20100,12200 L 20550,12350 20550,12050 20100,12200 Z"/>
+       </g>
+      </g>
+      <g class="com.sun.star.drawing.TextShape">
+       <g id="id16">
+        <rect class="BoundingBox" stroke="none" fill="none" x="23000" y="9474" width="4601" height="3027"/>
+        <text class="TextShape"><tspan class="TextParagraph" font-family="Liberation Sans, sans-serif" font-size="494px" font-weight="400"><tspan class="TextPosition" x="23250" y="10048"><tspan fill="rgb(0,0,0)" stroke="none">A les droits </tspan></tspan><tspan class="TextPosition" x="23250" y="10603"><tspan fill="rgb(0,0,0)" stroke="none">d’administrations </tspan></tspan><tspan class="TextPosition" x="23250" y="11158"><tspan fill="rgb(0,0,0)" stroke="none">sur les pages en </tspan></tspan><tspan class="TextPosition" x="23250" y="11713"><tspan fill="rgb(0,0,0)" stroke="none">tant que </tspan></tspan><tspan class="TextPosition" x="23250" y="12268"><tspan fill="rgb(0,0,0)" stroke="none">propriétaire</tspan></tspan></tspan></text>
+       </g>
+      </g>
+     </g>
+    </g>
+   </g>
+  </g>
+ </g>
+</svg>

+ 1 - 0
doc/images/v.svg

@@ -0,0 +1 @@
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle fill="#00875A" cx="8" cy="8" r="8"/><path d="M10.242 4.474a.941.941 0 0 1 1.634.934L8.11 11.996a.941.941 0 0 1-1.54.136L4.218 9.308a.941.941 0 1 1 1.446-1.205L7.15 9.886l3.093-5.412z" stroke="#FFF" stroke-width=".2" fill="#FFF" fill-rule="nonzero"/></g></svg>

+ 1 - 0
doc/images/x.svg

@@ -0,0 +1 @@
+<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><circle fill="#DE350B" cx="8" cy="8" r="8"/><path d="M9.485 8.071l2.122 2.121a1 1 0 1 1-1.415 1.415l-2.12-2.122-2.122 2.122a1 1 0 1 1-1.414-1.415l2.12-2.12-2.12-2.122A1 1 0 1 1 5.95 4.536l2.121 2.12 2.121-2.12a1 1 0 1 1 1.415 1.414L9.485 8.07z" fill="#FFF"/></g></svg>

+ 98 - 0
doc/installation.md

@@ -0,0 +1,98 @@
+
+## Détails de l'installation
+
+Typo3 est installé sur prod-front et sur preprod en double instance:
+
+| Instance | Mini-sites | Portail |
+| --- | --- | --- |
+| vhost | 001-sub.opentalent.fr.conf | 001-opentalent.fr.conf |
+| php | 7.4 | 7.0 |
+| Version Typo3 | 9.5 | 8.7 |
+| source directory | /var/www/typo3 | /var/www/typo3_82 |
+| Url du backend | https://ohcluses.opentalent.fr/typo3/ | https://opentalent.fr/typo3/ |
+
+
+**Important: Pour la suite de cette documentation, on ne parlera plus que de l'installation la plus récente (v9.5)**
+
+
+Les [extensions Opentalent](..) sont installées dans: /var/opentalent/git/ot_typo3
+
+## Opérations courantes
+
+### Faire une sauvegarde des données
+
+
+Faire un dump compressé de la DB (requiert environ 1Go en 2021):
+
+
+    mysqldump --single-transaction --compress -u dbcloner --password=**** typo3 | gzip -c > dump_typo3_yyyymmdd.gz
+
+
+Sauvegarder la configuration des sites (volume négligeable)
+
+
+    tar czvf ./sites.tar.gz /var/www/typo3/config/sites
+
+
+Sauvegarder les uploads et les définitions des formulaires (de l'ordre de la centaine de Go):
+
+
+    tar czvf ./fileadmin.tar.gz /var/www/typo3/public/fileadmin
+
+
+### Déployer une nouvelle version des extensions
+
+
+    ssh -A exploitation@[preprod ou prod-front]
+    cd /var/opentalent/git/ot_typo3
+    git pull
+
+
+### Mettre à jour Typo3
+
+
+
+Pour voir les dernières versions disponibles:
+
+    composer outdated
+
+Pour mettre à jour typo3:
+
+    composer update
+
+Pour tout mettre à jour:
+
+    composer update
+
+
+### Synchroniser les données depuis prod-front sur preprod
+
+
+    ssh -A exploitation@preprod
+
+    # databases
+    cd ~/clonedb
+    python3.8 clonedb.py typo3
+    
+    # files
+    rsync -av --delete exploitation@prod-front:/var/www/typo3/config/sites/ /var/www/typo3/config/sites/
+    rsync -av --delete exploitation@prod-front:/var/www/typo3/public/fileadmin/ /var/www/typo3/public/fileadmin/
+    
+    rsync -av --delete exploitation@prod-front:/var/www/typo3/archive/ /var/www/typo3/archive/
+
+
+### Mettre en mode maintenance
+
+Pour mettre une instance Typo3 en mode maintenance, accéder au serveur en ssh et éditer le .htaccess:
+
+    ssh -A exploitation@preprod
+    nano /var/www/typo3/public/.htaccess
+
+Et décommenter les lignes:
+
+    #RewriteCond %{REMOTE_ADDR} !^10\.8\.0\.[0-255]
+    #RewriteCond %{REQUEST_URI} !/maintenance.html$ [NC]
+    #RewriteRule .* /maintenance.html [R=302,L]
+
+Puis recommenter pour désactiver le mode maintenance.
+

+ 2 - 0
doc/maintenance.md

@@ -0,0 +1,2 @@
+# Opérations de maintenance et scheduler
+

+ 14 - 0
doc/ot_websites.md

@@ -0,0 +1,14 @@
+# La table ot_website
+
+La table `ot_websites`, spécifique à l'instance Typo3 Opentalent, est ajoutée à la base de données par l'extension
+[ot_core](/ot_core).
+
+Cette table est centrale dans le fonctionnement des extensions Opentalent, car c'est elle qui recense les sites des 
+strucures, leurs paramètres, domaines, statuts...
+
+## La variable globale ot_website
+
+## Le rôle de la table dans le routing
+
+## La génération dynamique de la configuration du site
+

+ 113 - 0
doc/readme.md

@@ -0,0 +1,113 @@
+# Typo3
+
+Typo3 est un [CMS opensource](https://typo3.fr/) adapté à la gestion d'une arborescence de sites.
+
+En 2021, l'usine à site Opentalent héberge près de 3800 sites internet (soit 63600 pages), 
+plus environ 2000 sites archivés.
+
+**Sommaire**
+
+* [Détails de l'installation et opérations courantes](installation.md)
+* [Versions et dépendances](dependencies.md)
+* [Backend users: droits des administrateurs et des éditeurs](be_users.md)
+* [Optimisation de la résolution d'url](routing.md)
+* [Gestion des formulaires](forms.md)
+
+
+
+## Objectifs
+
+Les objectifs principaux de cette usine à site sont les suivants:
+
+* Fournir un site internet à chaque structure cliente, ainsi qu'un compte administrateur pour ce même site
+* Permettre à chaque structure de créer, modifier, et supprimer les pages et leurs contenus, dans la limite des droits 
+  que leurs confèrent leur licence Opentalent
+* Intégrer automatiquement les flux de données issues du réseau Opentalent (évènements, partenaires, structures adhérentes...)
+* Donner un lien d'accès à l'espace membres de la structure (i.e. le logiciel Opentalent)
+
+
+A ces objectifs initiaux s'ajoutent certaines fonctionnalités attendues, dont les principales:
+
+* la possibilité de choisir entre plusieurs templates de site
+* l'intégration des réseaux sociaux
+* un système de gestion des actualités
+* le respect de la réglementation (cookies, RGPD, mentions légales)
+* la possibilité de restreindre l'accès à certaines pages aux seuls adhérents
+* la possibilité d'attribuer les droits d'éditions à d'autres utilisateurs membres de la structure
+* un système de suivi des statistiques de consultation du site
+* un formulaire de contact
+
+## Difficultés et choix techniques
+
+### Modularité et dépendances
+
+Typo3 intègre un système d'extensions bien conçu, ce qui autorise un 
+développement organisé et modulaire dans lequel chaque problématique peut correspondre à une extension dédiée.
+
+Cependant, le risque lié à une multiplication des extensions reste une interdépendance de ces extensions (l'effet "spaghetti")
+
+Pour éviter ça, on se tiendra donc à ce principe: 
+
+* l'extension "noyau" [OtCore](/ot_core) ne dépend d'aucune autre extension 
+* toutes les autres extensions ne peuvent dépendre que de la seule extension: [OtCore](/ot_core)
+
+```mermaid
+graph TD;
+    OtCore-->OtAdmin;
+    OtCore-->OtTemplating;
+    OtCore-->OtConnect;
+    OtCore-->OtStats;
+    OtCore-->...etc.;
+```
+
+### Design
+
+La multiplicité des contenus, des templates et des supports rend le design graphique délicat à maintenir.
+
+Afin de permettre une maintenabilité du code front, les choix suivants ont été faits:
+
+* une extension unique est responsable du templating: [OtTemplating](/ot_templating)
+* chaque template est indépendant des autres, et dispose de ses propres layouts, partials, assets
+* cependant, tous les templates implémentent les même modèles de page (page d'accueil, liste des évènements, liste des adhérents...)
+* les assets de chaque template sont organisés de manière similaire, en respectant entre autre la méthode [SMACCS](http://smacss.com/files/smacss-fr.pdf)
+* un/des site(s) de démonstration, contenant tous les types de pages et de contenus, permettent de constater l'aspect graphique de ces éléments.
+
+### Authentification
+
+Le partage d'une authentification entre le logiciel et les sites est effectué par l'intermédiaire de cookies.
+
+L'extension dédiée [OtConnect](/ot_connect) permet de soumettre ces cookies à l'API pour obtenir l'identité de l'utilisateur connecté.
+
+Ce fonctionnement rencontre cependant des difficultés liées aux règles de sécurité des navigateurs liées aux cookies,
+ainsi qu'aux structures possédant leur propre nom de domaine (domaines qui ne sont donc plus en `.opentalent.fr`)
+
+### Nombre de sites
+
+Tout adapté qu'il soit à l'architecture multi-sites, Typo3 n'a pas été pensé pour héberger une telle quantité de sites.
+De nombreuses difficultés liées à ce nombre ont donc dû être contournées.
+
+C'est la raison d'être de l'extension [OtOptimizer](/ot_admin), dont le rôle est de surcharger le fonctionnement de certaines fonctions
+natives de Typo3 pour des raisons de performance.
+
+### Cohérence avec les données Opentalent
+
+Les sites doivent être à tout moment cohérents avec les données de la base de données Opentalent (structures, utilisateurs, paramètre...).
+
+Pour ce faire, l'extension [OtAdmin](/ot_admin) fournit une API accessible par HTTP et en ligne de commande.
+Cette API est appellées par des hooks côté logiciel et permet de déclencher par exemple la création d'un nouveau site, sa mise à jour,
+ou son archivage.
+
+### Typoscript
+
+LE Typoscript. C'est le langage historique de Typo3. Très puissant, mais... [fiou](https://docs.typo3.org/m/typo3/tutorial-typoscript-in-45-minutes/master/en-us/TypoScriptFunctions/If/Index.html).
+
+Bref, le Typoscript étant assez difficile d'accès, le choix a été fait de privilégier l'usage du langage de 
+templating [Fluid](https://typo3.org/fluid/) dès que c'était possible.
+
+Plus d'infos [ici](dependencies.md).
+
+-------------------------
+
+Et si vous avez lu jusqu'ici, vous avez mérité un cookie:
+
+![cookie](images/cookie.png)

+ 93 - 0
doc/routing.md

@@ -0,0 +1,93 @@
+# Optimisation de la résolution d'URLs (routing)
+
+
+## Problématique initiale
+
+Typo3 n'a pas été pensé pour héberger des milliers de sites comme c'est le cas ici, Ce qui provoque un certain nombre de lenteurs, au niveau de l'affichage de certains écrans du backend par exemple. Une conséquence est que la résolution des urls par le système de routing est longue et consomme trop de puissance de calcul, causant à la fois une dégradation des performances et un risque de déni de service.
+
+Le problème se pose à la fois dans le cas d'une page existante (200) et d'une page inexistante (404), car les erreurs 404 impliquent la même charge de travail pour le serveur que les pages existantes.
+
+Deux étapes sont principalement concernées:
+
+
+### La résolution du domaine
+
+Pour associer le domaine utilisé à un site, le routeur Typo3 parse les fichiers de configuration *config.yaml* stockés dans `/var/www/typo3/config/sites`
+
+Il existe un fichier par site, soit près de 6000 dans notre cas en 2021.
+
+Au premier affichage, le temps d'accès disque et de parsing est donc extrêmement long.
+
+Lors des accès suivants, ces configurations sont en cache, ce qui réduit l'impact.
+
+Cependant, le router instancie tous les sites avant de faire finalement correspondre le domaine utilisé dans la requête à l'uid de la page racine d'un site.
+
+![blackfire_1](images/routing_blackfire_1.png)
+
+
+### La résolution du chemin
+
+Une fois la correspondance entre le domaine et un site effectuée, une deuxième étape consiste à résoudre le chemin 
+(path, ou slug) et à le faire correspondre à une page.
+
+Encore une fois, typo3 n'est pas conçu pour héberger autant de site. Pour effectuer cette résolution, Typo3 récupère en 
+base toutes les pages dont le slug correspond à la requête. Ensuite, le routeur vérifie pour chacune de ces pages 
+si elle fait partie du site déterminé à l'étape précédente.
+
+Ce qui implique, lorsque la requête concerne un slug commun comme la page d'accueil '/', que jusqu'à 6000 requêtes peuvent 
+être effectuées en base pour la résolution d'une seule page!
+
+
+
+## Mesures d'optimisation
+
+Pour parer à ces problèmes, l'extension [ot_optimizer](/ot_optimizer) implémente des [XClass](https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Xclasses/Index.html) 
+qui vont se substituer à certaines classes Typo3 responsable du routing.
+
+Le rôle de ces classes sera donc de faire correspondre un domaine à un site via les enregistrements de la table 
+'ot_websites', puis un slug à l'uid d'une page grâce au champs 'slug' de la table 'pages'.
+
+**IMPORTANT**: ces mesures ne sont appliquées qu'aux requêtes Frontend.
+
+
+### Garder les routes à jour
+
+Un hook est nécessaire dans le logiciel pour garder à jour le champs 'subdomain' de la table 'ot_websites'. 
+Ce hook est déclenché par la requête http: `<typo3_host>/typo3/index.php?route=/otadmin/site/update&organization-id=<organization_id>`
+
+La redirection peut-être ajoutée en ligne de commande (voir [ot_admin](/ot_admin))
+
+
+### Faut-il prévoir un fallback vers le fonctionnement par défaut?
+
+Aucun fallback vers le router natif de Typo3 n'est prévu, car un fonctionnement de ce genre annulerait les 
+bénéfices de l'index dans le cas des erreurs 404.
+
+
+### Cas particuliers
+
+#### Gérer les redirections
+
+Certains domaines font l'objet de redirections vers un autre nom de domaine. Il faut s'assurer que cette redirection puisse avoir lieu.
+
+#### Les pages à accès restreint
+
+Les pages à accès restreint doivent apparaître dans la table de routage, Typo3 se chargera ensuite de déterminer 
+si l'utilisateur a le droit d'y accéder ou non.
+
+#### Les pages de type raccourci
+
+Les pages de type raccourci doivent apparaître dans la table de routage comme des pages normales, Typo3 se chargera 
+ensuite de la redirection. En effet, si on voulait gérer cette redirection directement au niveau de la table de routage, 
+il faudrait tenir compte du cas supplémentaire où la cible du raccourci est changée.
+
+#### Les urls en mode développement
+
+En mode développement (sur preprod ou en local), les urls ne sont pas construites de la même manière:
+
+| Prod | Dev |
+| --- | --- |
+| `<subdomain>.opentalent.fr` | `host.opentalent.fr/<subdomain> |
+
+ 
+

+ 1 - 0
doc/social_networks.md

@@ -0,0 +1 @@
+# Intégration des réseaux sociaux

+ 2 - 0
doc/stats.md

@@ -0,0 +1,2 @@
+# Fonctionnement du suivi des stats d'utilisation des sites
+

+ 2 - 0
doc/templating.md

@@ -0,0 +1,2 @@
+# Fonctionnement du multi-templating
+

+ 1 - 0
doc/tests.md

@@ -0,0 +1 @@
+# Tests automatisés

+ 6 - 3
docker/conf/composer.json

@@ -42,10 +42,12 @@
 		"fluidtypo3/vhs": "^6.0",
 		"georgringer/news": "^8.3",
 		"helhum/typo3-console": "^5.7",
+		"causal/image_autoresize": "^2.0",
 		"guzzlehttp/guzzle": "^6",
 		"friendsoftypo3/frontend-editing": "^1.9",
-		"sgalinski/lfeditor": "^6.0"
-    },
+		"sgalinski/lfeditor": "^6.0",
+		"twig/twig": "^2.13"
+	},
 	"scripts":{
 		"typo3-cms-scripts": [
 			"typo3cms install:fixfolderstructure",
@@ -61,7 +63,8 @@
 			"Opentalent\\OtConnect\\": "public/typo3conf/ext/ot_connect/Classes",
 			"Opentalent\\OtTemplating\\": "public/typo3conf/ext/ot_templating/Classes",
 			"Opentalent\\OtAdmin\\": "public/typo3conf/ext/ot_admin/Classes",
-			"Opentalent\\OtStats\\": "public/typo3conf/ext/ot_stats/Classes"
+			"Opentalent\\OtStats\\": "public/typo3conf/ext/ot_stats/Classes",
+			"Opentalent\\OtOptimizer\\": "public/typo3conf/ext/ot_optimizer/Classes"
 		}
 	}
 }

+ 70 - 0
ot_admin/Classes/Command/AddRedirectionCommand.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace Opentalent\OtAdmin\Command;
+
+
+use Opentalent\OtAdmin\Controller\SiteController;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * This CLI command adds a redirection from a domain to another
+ *
+ * @package Opentalent\OtAdmin\Command
+ */
+class AddRedirectionCommand extends Command
+{
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Allows to configure the command.
+     * Allows to add a description, a help text, and / or define arguments.
+     *
+     */
+    protected function configure()
+    {
+        $this
+            ->setName("ot:redirection:add")
+            ->setDescription("Add a redirection from a domain to another")
+            ->setHelp("Add a redirection from a domain to another")
+            ->addArgument(
+                'from-domain',
+                InputArgument::REQUIRED,
+                "The domain to be redirected"
+            )->addArgument(
+                'to-domain',
+                InputArgument::REQUIRED,
+                "The target domain"
+            );
+    }
+
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $fromDomain = $input->getArgument('from-domain');
+        $toDomain = $input->getArgument('to-domain');
+
+        $io = new SymfonyStyle($input, $output);
+
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $status = $siteController->addRedirection($fromDomain, $toDomain);
+
+        if ($status == SiteController::REDIRECTION_UPDATED) {
+            $io->success(sprintf("A existing redirection has been restored and updated"));
+        } else {
+            $io->success(sprintf("A new redirection has been added"));
+        }
+    }
+}

+ 71 - 0
ot_admin/Classes/Command/ClearSiteCacheCommand.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace Opentalent\OtAdmin\Command;
+
+
+use Opentalent\OtAdmin\Controller\SiteController;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * This CLI command clears the cache of an existing organization's website
+ *
+ * By default, this command will only clear the frontend cache.
+ * Pass the '-a / --all' option to clear all of the typo3 caches.
+ *
+ * @package Opentalent\OtAdmin\Command
+ */
+class ClearSiteCacheCommand extends Command
+{
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Allows to configure the command.
+     * Allows to add a description, a help text, and / or define arguments.
+     *
+     */
+    protected function configure()
+    {
+        $this
+            ->setName("ot:site:clear-cache")
+            ->setDescription("Clear the cache of an organization website")
+            ->setHelp("This CLI command clears the cache of an existing organization's website")
+            ->addArgument(
+                'organization-id',
+                InputArgument::REQUIRED,
+                "The organization's id in the opentalent DB"
+            )->addOption(
+                'all',
+                'a',
+                InputOption::VALUE_NONE,
+                'Use this option to clear all the typo3 caches, and not only the frontend one'
+            );
+    }
+
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $org_id = $input->getArgument('organization-id');
+        $clearAll = $input->getOption('all');
+
+        $io = new SymfonyStyle($input, $output);
+
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $rootUid = $siteController->clearSiteCacheAction($org_id, $clearAll);
+
+        $io->success(sprintf("The cache has been cleared for the website with root uid " . $rootUid . ""));
+    }
+
+}

+ 8 - 10
ot_admin/Classes/Command/CreateSiteCommand.php

@@ -10,6 +10,8 @@ use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
 
 /**
  * This CLI command creates an organization's website
@@ -19,8 +21,6 @@ use Symfony\Component\Console\Style\SymfonyStyle;
  */
 class CreateSiteCommand extends Command
 {
-
-
     /**
      * -- This method is expected by Typo3, do not rename ou remove --
      *
@@ -35,37 +35,35 @@ class CreateSiteCommand extends Command
             ->setDescription("Create an organization's website " .
                                        "by fetching its latest data from the Opentalent API")
             ->setHelp("Call this method by giving it the organization's id in the Opentalent DB. 
-                            If no site exists, create it; 
-                            If a site already exists, do nothing.")
+                            If no site exists, it will create it; 
+                            If a site already exists, it won't do anything.")
             ->addArgument(
-                'organization_id',
+                'organization-id',
                 InputArgument::REQUIRED,
                 "The organization's id in the opentalent DB"
             )->addOption(
                 'dev',
                 null,
                 InputOption::VALUE_NONE,
-                "Add this option to get url like 'http://host/subdomain' instead of 'http://subdomain/host'"
+                "Add this option to get url like 'http://host/subdomain' instead of 'http://subdomain.host'"
             );
     }
 
     /**
      * -- This method is expected by Typo3, do not rename ou remove --
      *
-     * Executes the command for creating the new organization
-     *
      * @param InputInterface $input
      * @param OutputInterface $output
      * @throws \Exception
      */
     protected function execute(InputInterface $input, OutputInterface $output)
     {
-        $org_id = $input->getArgument('organization_id');
+        $org_id = $input->getArgument('organization-id');
         $isDev = $input->getOption('dev');
 
         $io = new SymfonyStyle($input, $output);
 
-        $siteController = new SiteController();
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
         $rootUid = $siteController->createSiteAction(
             $org_id,
             $isDev ? $siteController::MODE_DEV : $siteController::MODE_PROD

+ 20 - 6
ot_admin/Classes/Command/DeleteSiteCommand.php

@@ -10,6 +10,8 @@ use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
 
 /**
  * This CLI command delete an existing organization's website
@@ -44,8 +46,20 @@ class DeleteSiteCommand extends Command
                 InputOption::VALUE_NONE,
                 "Permanently delete the records and files. Use with caution."
             )
+            ->addOption(
+                'redirect-to',
+                'r',
+                InputOption::VALUE_REQUIRED,
+                "Organization id of the website to which add a redirection."
+            )
+            ->addOption(
+                'force',
+                'f',
+                InputOption::VALUE_NONE,
+                "Force the deletion of website directories, even if they are not empty (this has no effect without the --hard option)"
+            )
             ->addArgument(
-                'organization_id',
+                'organization-id',
                 InputArgument::REQUIRED,
                 "The organization's id in the opentalent DB"
             );
@@ -54,21 +68,21 @@ class DeleteSiteCommand extends Command
     /**
      * -- This method is expected by Typo3, do not rename ou remove --
      *
-     * Executes the command for creating the new organization
-     *
      * @param InputInterface $input
      * @param OutputInterface $output
      * @throws \Exception
      */
     protected function execute(InputInterface $input, OutputInterface $output)
     {
-        $org_id = $input->getArgument('organization_id');
+        $org_id = $input->getArgument('organization-id');
         $hard = $input->getOption('hard');
+        $force = $input->getOption('force');
+        $redirectTo = $input->getOption('redirect-to');
 
         $io = new SymfonyStyle($input, $output);
 
-        $siteController = new SiteController();
-        $rootUid = $siteController->deleteSiteAction($org_id, $hard);
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $rootUid = $siteController->deleteSiteAction($org_id, $hard, $redirectTo, $force);
 
         if ($hard) {
             $io->success(sprintf("The website with root uid " . $rootUid . " has been permanently deleted"));

+ 84 - 0
ot_admin/Classes/Command/GetSiteStatusCommand.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace Opentalent\OtAdmin\Command;
+
+
+use Opentalent\OtAdmin\Controller\SiteController;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * This CLI command returns the status and some useful informations
+ * about the given website
+ *
+ * @package Opentalent\OtAdmin\Command
+ */
+class GetSiteStatusCommand extends Command
+{
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Allows to configure the command.
+     * Allows to add a description, a help text, and / or define arguments.
+     *
+     */
+    protected function configure()
+    {
+        $this
+            ->setName("ot:site:status")
+            ->setDescription("Displays the current state of a given website")
+            ->setHelp("This CLI command returns a status code representing the current state of a given website")
+            ->addOption(
+                'full',
+                null,
+                InputOption::VALUE_NONE,
+                "Performs a full scan (with warnings)"
+            )
+            ->addArgument(
+                'organization-id',
+                InputArgument::REQUIRED,
+                "The organization's id in the opentalent DB"
+            );
+    }
+
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $org_id = $input->getArgument('organization-id');
+        $full = $input->getOption('full');
+
+        $io = new SymfonyStyle($input, $output);
+
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $status = $siteController->getSiteStatusAction($org_id, $full);
+
+        $headers = [];
+        $values = [];
+        foreach ($status->toArray() as $key => $value) {
+            $headers[] = $key;
+            $value = json_encode(
+                str_replace("\"", "'", $value),
+                JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
+            );
+            $value = trim($value, "\"");
+            $values[] = $value;
+        }
+
+        $io->horizontalTable($headers, [$values]);
+
+        $io->success('');
+    }
+
+}

+ 68 - 0
ot_admin/Classes/Command/RemoveRedirectionCommand.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace Opentalent\OtAdmin\Command;
+
+
+use Opentalent\OtAdmin\Controller\SiteController;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * This CLI command removes any redirection from the given domain
+ *
+ * @package Opentalent\OtAdmin\Command
+ */
+class RemoveRedirectionCommand extends Command
+{
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Allows to configure the command.
+     * Allows to add a description, a help text, and / or define arguments.
+     *
+     */
+    protected function configure()
+    {
+        $this
+            ->setName("ot:redirection:remove")
+            ->setDescription("Remove any redirection from the given domain")
+            ->setHelp("Remove any redirection from the given domain. If --hard is true, they will be permanently deleted.")
+            ->addArgument(
+                'from-domain',
+                InputArgument::REQUIRED,
+                "The source domain"
+            )->addOption(
+                'hard',
+                null,
+                InputOption::VALUE_NONE,
+                "Permanently delete the records and files. Use with caution."
+            );
+    }
+
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $fromDomain = $input->getArgument('from-domain');
+        $hard = $input->getOption('hard');
+
+        $io = new SymfonyStyle($input, $output);
+
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $count = $siteController->removeRedirectionsFrom($fromDomain, $hard);
+
+        $actionName = $hard ? 'hardly-deleted' : 'deleted';
+        $io->success(sprintf("$count existing redirections from $fromDomain have been $actionName"));
+    }
+}

+ 113 - 0
ot_admin/Classes/Command/ResetBeUserPermsCommand.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace Opentalent\OtAdmin\Command;
+
+
+use Opentalent\OtAdmin\Controller\SiteController;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * This CLI command resets the permissions granted to
+ * this website be users
+ *
+ * @package Opentalent\OtAdmin\Command
+ */
+class ResetBeUserPermsCommand extends Command
+{
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Allows to configure the command.
+     * Allows to add a description, a help text, and / or define arguments.
+     *
+     */
+    protected function configure()
+    {
+        $this
+            ->setName("ot:site:reset-perms")
+            ->setDescription("Reset the permissions granted to the be users")
+            ->setHelp("This CLI command resets the permissions granted to 
+                            either one website be users, or every websites be users")
+            ->addArgument(
+                'organization-id',
+                InputArgument::OPTIONAL,
+                "The organization's id in the opentalent DB"
+            )
+            ->addOption(
+                "all",
+                null,
+                InputOption::VALUE_NONE,
+                "Reset all of the websites be_users permissions."
+            )
+            ->addOption(
+                "create",
+                'c',
+                InputOption::VALUE_NONE,
+                "Create the be_user and/or be_group when they are missing"
+            );
+    }
+
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $org_id = $input->getArgument('organization-id');
+        $all = $input->getOption('all');
+        $create = $input->getOption('create');
+
+        if ($all && $org_id) {
+            throw new \InvalidArgumentException("You can not pass both an organization id and the --all option");
+        }
+        if (!$all && !$org_id) {
+            throw new \InvalidArgumentException("You shall either pass an organization id or use the --all option");
+        }
+
+        $io = new SymfonyStyle($input, $output);
+
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+
+        if ($all) {
+            $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
+            $queryBuilder = $connectionPool->getQueryBuilderForTable('ot_websites');
+            $sites = $queryBuilder
+                ->select('organization_id')
+                ->from('ot_websites')
+                ->where($queryBuilder->expr()->eq('deleted', 0))
+                ->andWhere($queryBuilder->expr()->gt('organization_id', 0))
+                ->execute()
+                ->fetchAll();
+
+            $io->progressStart(count($sites));
+
+            foreach ($sites as $site) {
+                $org_id = $site['organization_id'];
+                try {
+                    $siteController->resetBeUserPermsAction($org_id, $create);
+                } catch (\Throwable $e) {
+                    $io->error('Organization Id: ' . $org_id . ' - ' . $e->getMessage());
+                }
+                $io->progressAdvance(1);
+            }
+            $io->progressFinish();
+
+            $io->success(sprintf("Be users permissions were reset for every website"));
+        } else {
+            $rootUid = $siteController->resetBeUserPermsAction($org_id, $create);
+            $io->success(sprintf("The website with root uid " . $rootUid . " had its be users permissions reset"));
+        }
+    }
+
+}

+ 86 - 0
ot_admin/Classes/Command/ScanCommand.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace Opentalent\OtAdmin\Command;
+
+use Opentalent\OtAdmin\Controller\ScanController;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * This CLI command performs a full scan on the Typo3 db
+ *
+ * @package Opentalent\OtAdmin\Command
+ */
+class ScanCommand extends Command
+{
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Allows to configure the command.
+     * Allows to add a description, a help text, and / or define arguments.
+     *
+     */
+    protected function configure()
+    {
+        $this
+            ->setName("ot:scan")
+            ->setDescription("Scan the typo3 DB")
+            ->setHelp("This CLI command performs a full scan on the Typo3 db.")
+            ->addOption(
+                'report',
+                'r',
+                InputOption::VALUE_OPTIONAL,
+                'Build an html report file with the given name.'
+            );
+    }
+
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $io = new SymfonyStyle($input, $output);
+
+        // instanciate twig loader
+        $loader = new \Twig\Loader\FilesystemLoader(dirname(__FILE__) . '/../../templates');
+        $twig = new \Twig\Environment($loader);
+
+        // evaluate the report path
+        $report_path = $input->getOption('report');
+        if ($report_path == null) {
+            $report_path = getcwd() . '/scan_report.html';
+        } else {
+            $info = pathinfo($report_path);
+            if ($info['extension'] != 'html') {
+                $report_path .= '.html';
+            }
+        }
+
+        // perform the scan
+        $scanController = GeneralUtility::makeInstance(ObjectManager::class)->get(ScanController::class);
+        $scan = $scanController->scanAllAction(true);
+
+        // render the twig template
+        $template = $twig->load('scan_report.twig');
+
+        $html_report = $template->render(['scan' => $scan]);
+
+        $f = fopen($report_path, 'w+');
+        try {
+            fwrite($f, $html_report);
+            $io->success(sprintf("Report file was created at: " . $report_path));
+        } finally {
+            fclose($f);
+        }
+    }
+
+}

+ 79 - 0
ot_admin/Classes/Command/SetSiteDomainCommand.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace Opentalent\OtAdmin\Command;
+
+
+use Opentalent\OtAdmin\Controller\SiteController;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * This CLI command sets a new domain for the organization website
+ *
+ * @package Opentalent\OtAdmin\Command
+ */
+class SetSiteDomainCommand extends Command
+{
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Allows to configure the command.
+     * Allows to add a description, a help text, and / or define arguments.
+     *
+     */
+    protected function configure()
+    {
+        $this
+            ->setName("ot:site:setdomain")
+            ->setDescription("Set a new domain for the organization website")
+            ->setHelp("Set a new domain for the organization website. A new redirection will " .
+                           "be added from the existing domain to the new one. Use the --no-redirection option " .
+                           "to prevent this.")
+            ->addArgument(
+                'organization-id',
+                InputArgument::REQUIRED,
+                "The organization's id in the opentalent DB"
+            )
+            ->addArgument(
+                'domain',
+                InputArgument::REQUIRED,
+                "The new domain to set up"
+            )
+            ->addOption(
+                'no-redirection',
+                'r',
+                InputOption::VALUE_NONE,
+                'Use this option to prevent the creation of a redirection from the previous domain to the new one'
+            );
+    }
+
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Executes the command for creating the new organization
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $org_id = $input->getArgument('organization-id');
+        $domain = $input->getArgument('domain');
+        $redirect = ($input->getOption('no-redirection') == null);
+
+        $io = new SymfonyStyle($input, $output);
+
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $rootUid = $siteController->setSiteCustomDomainAction($org_id, $domain, $redirect);
+
+        $io->success(sprintf("The website with root uid " . $rootUid . " domain has been set to " . $domain));
+    }
+
+}

+ 5 - 7
ot_admin/Classes/Command/UndeleteSiteCommand.php

@@ -10,6 +10,8 @@ use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
 
 /**
  * This CLI command undo the soft-deletion an existing organization's website
@@ -18,8 +20,6 @@ use Symfony\Component\Console\Style\SymfonyStyle;
  */
 class UndeleteSiteCommand extends Command
 {
-
-
     /**
      * -- This method is expected by Typo3, do not rename ou remove --
      *
@@ -37,7 +37,7 @@ class UndeleteSiteCommand extends Command
                             command and without the --hard option, you can undo the deletion with
                             this command.")
             ->addArgument(
-                'organization_id',
+                'organization-id',
                 InputArgument::REQUIRED,
                 "The organization's id in the opentalent DB"
             );
@@ -46,19 +46,17 @@ class UndeleteSiteCommand extends Command
     /**
      * -- This method is expected by Typo3, do not rename ou remove --
      *
-     * Executes the command for creating the new organization
-     *
      * @param InputInterface $input
      * @param OutputInterface $output
      * @throws \Exception
      */
     protected function execute(InputInterface $input, OutputInterface $output)
     {
-        $org_id = $input->getArgument('organization_id');
+        $org_id = $input->getArgument('organization-id');
 
         $io = new SymfonyStyle($input, $output);
 
-        $siteController = new SiteController();
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
         $rootUid = $siteController->undeleteSiteAction($org_id);
 
         $io->success(sprintf("The website with root uid " . $rootUid . " has been restored"));

+ 103 - 0
ot_admin/Classes/Command/UpdateRoutingIndexCommand.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace Opentalent\OtAdmin\Command;
+
+
+use Opentalent\OtAdmin\Controller\SiteController;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * This CLI command updates the routing index for the given website
+ *
+ * @package Opentalent\OtAdmin\Command
+ */
+class UpdateRoutingIndexCommand extends Command
+{
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * Allows to configure the command.
+     * Allows to add a description, a help text, and / or define arguments.
+     *
+     */
+    protected function configure()
+    {
+        $this
+            ->setName("ot:site:index")
+            ->setDescription("Update the routes index for the given website(s)")
+            ->setHelp("This CLI command updates the routing index for the given website")
+            ->addOption(
+                'all',
+                null,
+                InputOption::VALUE_NONE,
+                "Update all of the organization websites"
+            )
+            ->addArgument(
+                'organization-id',
+                InputArgument::OPTIONAL,
+                "The organization's id in the opentalent DB"
+            );
+    }
+
+    /**
+     * -- This method is expected by Typo3, do not rename ou remove --
+     *
+     * @param InputInterface $input
+     * @param OutputInterface $output
+     * @throws \Exception
+     */
+    protected function execute(InputInterface $input, OutputInterface $output)
+    {
+        $org_id = $input->getArgument('organization-id');
+        $all = $input->getOption('all');
+
+        if ($all && $org_id) {
+            throw new \InvalidArgumentException("You can not pass both an organization id and the --all option");
+        }
+        if (!$all && !$org_id) {
+            throw new \InvalidArgumentException("You shall either pass an organization id or use the --all option");
+        }
+
+        $io = new SymfonyStyle($input, $output);
+
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+
+        if ($all) {
+            $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
+            $queryBuilder = $connectionPool->getQueryBuilderForTable('ot_websites');
+            $sites = $queryBuilder
+                ->select('organization_id')
+                ->from('ot_websites')
+                ->where($queryBuilder->expr()->eq('deleted', 0))
+                ->andWhere($queryBuilder->expr()->gt('organization_id', 0))
+                ->execute()
+                ->fetchAll();
+
+            $io->progressStart(count($sites));
+
+            foreach ($sites as $site) {
+                $org_id = $site['organization_id'];
+                try {
+                    $siteController->updateRoutingIndexAction($org_id);
+                } catch (\Throwable $e) {
+                    $io->error('Organization Id: ' . $org_id . ' - ' . $e->getMessage());
+                }
+                $io->progressAdvance(1);
+            }
+            $io->progressFinish();
+
+            $io->success(sprintf("The routing index has all been fully updated"));
+        } else {
+            $rootUid = $siteController->updateRoutingIndexAction($org_id);
+            $io->success(sprintf("The website with root uid " . $rootUid . " routing index has been updated"));
+        }
+    }
+}

+ 69 - 10
ot_admin/Classes/Command/UpdateSiteCommand.php

@@ -4,15 +4,19 @@ namespace Opentalent\OtAdmin\Command;
 
 
 use Opentalent\OtAdmin\Controller\SiteController;
+use Opentalent\OtCore\Exception\NoSuchOrganizationException;
 use Symfony\Component\Console\Command\Command;
 use Symfony\Component\Console\Input\InputArgument;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Input\InputOption;
 use Symfony\Component\Console\Output\OutputInterface;
 use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
 
 /**
- * This CLI command update an existing organization's website
+ * This CLI command updates an existing organization's website
  * with the latest data from the Opentalent DB
  *
  * @package Opentalent\OtAdmin\Command
@@ -33,32 +37,87 @@ class UpdateSiteCommand extends Command
             ->setDescription("Update an organization website")
             ->setHelp("This CLI command update an existing organization's website
                             with the latest data from the Opentalent DB")
+            ->addOption(
+                'all',
+                null,
+                InputOption::VALUE_NONE,
+                "Update all of the organization websites"
+            )
             ->addArgument(
-                'organization_id',
-                InputArgument::REQUIRED,
+                'organization-id',
+                InputArgument::OPTIONAL,
                 "The organization's id in the opentalent DB"
+            )
+            ->addOption(
+                'delete',
+                null,
+                InputOption::VALUE_NONE,
+                "Performs a soft deletion of the websites when the organization does not exist anymore " .
+                "in the Opentalent DB. (This only applies if the --all option is used)"
             );
     }
 
     /**
      * -- This method is expected by Typo3, do not rename ou remove --
      *
-     * Executes the command for creating the new organization
-     *
      * @param InputInterface $input
      * @param OutputInterface $output
      * @throws \Exception
      */
     protected function execute(InputInterface $input, OutputInterface $output)
     {
-        $org_id = $input->getArgument('organization_id');
+        $org_id = $input->getArgument('organization-id');
+        $all = $input->getOption('all');
+        $delete = $input->getOption('delete');
+
+        if ($all && $org_id) {
+            throw new \InvalidArgumentException("You can not pass both an organization id and the --all option");
+        }
+        if (!$all && !$org_id) {
+            throw new \InvalidArgumentException("You shall either pass an organization id or use the --all option");
+        }
+        if (!$all && $delete) {
+            throw new \InvalidArgumentException("The delete option only applies when the --all option is passed");
+        }
 
         $io = new SymfonyStyle($input, $output);
 
-        $siteController = new SiteController();
-        $rootUid = $siteController->updateSiteConstantsAction($org_id);
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
 
-        $io->success(sprintf("The website with root uid " . $rootUid . " has been updated"));
-    }
+        if ($all) {
+            $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
+            $queryBuilder = $connectionPool->getQueryBuilderForTable('ot_websites');
+            $sites = $queryBuilder
+                ->select('organization_id')
+                ->from('ot_websites')
+                ->where($queryBuilder->expr()->eq('deleted', 0))
+                ->andWhere($queryBuilder->expr()->gt('organization_id', 0))
+                ->execute()
+                ->fetchAll();
 
+            $io->progressStart(count($sites));
+
+            foreach ($sites as $site) {
+                $org_id = $site['organization_id'];
+                try {
+                    $siteController->updateSiteAction($org_id);
+                } catch (NoSuchOrganizationException $e) {
+                    if ($delete) {
+                        $siteController->deleteSiteAction($org_id);
+                    } else {
+                        throw $e;
+                    }
+                } catch (\Throwable $e) {
+                    $io->error('Organization Id: ' . $org_id . ' - ' . $e->getMessage());
+                }
+                $io->progressAdvance(1);
+            }
+            $io->progressFinish();
+
+            $io->success(sprintf("The websites have all been updated"));
+        } else {
+            $rootUid = $siteController->updateSiteAction($org_id);
+            $io->success(sprintf("The website with root uid " . $rootUid . " has been updated"));
+        }
+    }
 }

+ 40 - 0
ot_admin/Classes/Controller/ScanController.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Opentalent\OtAdmin\Controller;
+
+use Opentalent\OtAdmin\Domain\Entity\ScanReport;
+use Opentalent\OtCore\Controller\ActionController;
+use Opentalent\OtCore\Domain\Repository\OrganizationRepository;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+
+/**
+ * ScanController scan the Typo3 DB to find potential issues
+ */
+class ScanController extends ActionController
+{
+    /**
+     * Perform a full scan of the Typo3 DB, and confront it to the
+     * Opentalent organizations list.
+     *
+     * @param bool $fullScan If true, a 'warnings' entry will be added to the result of each site status,
+     *                        and a full scan of the website pages will be performed.
+     * @return ScanReport
+     */
+    public function scanAllAction(bool $fullScan = false): ScanReport
+    {
+        $organizationRepository = GeneralUtility::makeInstance(ObjectManager::class)->get(OrganizationRepository::class);
+        $siteController = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+
+        $report = new ScanReport();
+
+        $organizationsCollection = $organizationRepository->getAll();
+        foreach ($organizationsCollection->getMembers() as $organization) {
+            $status = $siteController->getSiteStatusAction($organization->getId(), $fullScan);
+
+            $report->setResultFor($organization->getId(), $status);
+        }
+
+        return $report;
+    }
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 441 - 192
ot_admin/Classes/Controller/SiteController.php


+ 83 - 0
ot_admin/Classes/Domain/Entity/ScanReport.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace Opentalent\OtAdmin\Domain\Entity;
+
+/**
+ * Report of a ScanController->scanAllAction() operation
+ */
+class ScanReport
+{
+    /**
+     * Date and time of the scan report
+     * @var \DateTime|null
+     */
+    protected ?\DateTime $datetime = null;
+
+    /**
+     * Numeric statistics (number of website in each state)
+     * @var array
+     */
+    protected array $stats = [];
+
+    /**
+     * Per-site results
+     * @var array
+     */
+    protected array $results = [];
+
+    public function __construct() {
+        $this->datetime = new \DateTime();
+    }
+
+    /**
+     * @return \DateTime
+     */
+    public function getDatetime(): \DateTime
+    {
+        return $this->datetime;
+    }
+
+    /**
+     * @return array
+     */
+    public function getStats(): array
+    {
+        return $this->stats;
+    }
+
+    /**
+     * @return array
+     */
+    public function getLabelledStats(): array
+    {
+        $labelled = [];
+        foreach ($this->stats as $k => $v) {
+            $labelled[SiteStatus::STATUSES[$k]] = $v;
+        }
+        return $labelled;
+    }
+
+    /**
+     * @return array
+     */
+    public function getResults(): array
+    {
+        return $this->results;
+    }
+
+    /**
+     * Set the result associated with the given organization id
+     *
+     * @param int $organizationId
+     * @param SiteStatus $siteStatus
+     */
+    public function setResultFor(int $organizationId, SiteStatus $siteStatus): void
+    {
+        $this->results[$organizationId] = $siteStatus;
+
+        if (!isset($this->stats[$siteStatus->getStatusCode()])) {
+            $this->stats[$siteStatus->getStatusCode()] = 0;
+        }
+        $this->stats[$siteStatus->getStatusCode()] += 1;
+    }
+}

+ 317 - 0
ot_admin/Classes/Domain/Entity/SiteInfos.php

@@ -0,0 +1,317 @@
+<?php
+
+namespace Opentalent\OtAdmin\Domain\Entity;
+
+/**
+ * Basic informations about a typo3 website
+ */
+class SiteInfos
+{
+    protected int $rootUid;
+    protected string $siteTitle = "";
+    protected string $baseUrl = "";
+    protected string $template = "";
+    protected string $preferences = "";
+    protected ?int $matomoId = null;
+    protected ?bool $isPremium = null;
+    protected bool $deleted = false;
+    protected bool $hiddenOrRestricted = false;
+    protected array $mountedForBeUsers = [];
+    protected array $mountedForBeGroups = [];
+    protected ?int $ownerUserUid = null;
+    protected ?int $ownerGroupUid = null;
+
+    /**
+     * SiteInfos constructor.
+     * @param int $rootUid
+     * @param string|null $siteTitle
+     * @param string|null $baseUrl
+     * @param string|null $template
+     * @param string|null $preferences
+     * @param int|null $matomoId
+     * @param bool $deleted
+     * @param bool $hiddenOrRestricted
+     * @param array|null $mountedForBeUsers
+     * @param array|null $mountedForBeGroups
+     * @param int|null $ownerUserUid
+     * @param int|null $ownerGroupUid
+     */
+    public function __construct(
+        int $rootUid,
+        string $siteTitle = null,
+        string $baseUrl = null,
+        string $template = null,
+        string $preferences = null,
+        int $matomoId = null,
+        bool $isPremium = null,
+        bool $deleted = false,
+        bool $hiddenOrRestricted = false,
+        array $mountedForBeUsers = null,
+        array $mountedForBeGroups = null,
+        int $ownerUserUid = null,
+        int $ownerGroupUid = null
+    )
+    {
+        $this->rootUid = $rootUid;
+        if ($siteTitle !== null) {
+            $this->siteTitle = $siteTitle;
+        }
+        if ($baseUrl !== null) {
+            $this->baseUrl = $baseUrl;
+        }
+        if ($template !== null) {
+            $this->template = $template;
+        }
+        if ($preferences !== null) {
+            $this->preferences = $preferences;
+        }
+        if ($matomoId !== null) {
+            $this->matomoId = $matomoId;
+        }
+        if ($isPremium !== null) {
+            $this->isPremium = $isPremium;
+        }
+        if ($deleted !== null) {
+            $this->deleted = $deleted;
+        }
+        if ($hiddenOrRestricted !== null) {
+            $this->hiddenOrRestricted = $hiddenOrRestricted;
+        }
+        if ($mountedForBeUsers !== null) {
+            $this->mountedForBeUsers = $mountedForBeUsers;
+        }
+        if ($mountedForBeGroups !== null) {
+            $this->mountedForBeGroups = $mountedForBeGroups;
+        }
+        if ($ownerUserUid !== null) {
+            $this->ownerUserUid = $ownerUserUid;
+        }
+        if ($ownerGroupUid !== null) {
+            $this->ownerGroupUid = $ownerGroupUid;
+        }
+    }
+
+    /**
+     * @return int
+     */
+    public function getRootUid(): int
+    {
+        return $this->rootUid;
+    }
+
+    /**
+     * @param int $rootUid
+     */
+    public function setRootUid(int $rootUid): void
+    {
+        $this->rootUid = $rootUid;
+    }
+
+    /**
+     * @return string
+     */
+    public function getSiteTitle(): string
+    {
+        return $this->siteTitle;
+    }
+
+    /**
+     * @param string $siteTitle
+     */
+    public function setSiteTitle(string $siteTitle): void
+    {
+        $this->siteTitle = $siteTitle;
+    }
+
+    /**
+     * @return string
+     */
+    public function getBaseUrl(): string
+    {
+        return $this->baseUrl;
+    }
+
+    /**
+     * @param string $baseUrl
+     */
+    public function setBaseUrl(string $baseUrl): void
+    {
+        $this->baseUrl = $baseUrl;
+    }
+
+    /**
+     * @return string
+     */
+    public function getTemplate(): string
+    {
+        return $this->template;
+    }
+
+    /**
+     * @param string $template
+     */
+    public function setTemplate(string $template): void
+    {
+        $this->template = $template;
+    }
+
+    /**
+     * @return string
+     */
+    public function getPreferences(): string
+    {
+        return $this->preferences;
+    }
+
+    /**
+     * @param string $preferences
+     */
+    public function setPreferences(string $preferences): void
+    {
+        $this->preferences = $preferences;
+    }
+
+    /**
+     * @return int
+     */
+    public function getMatomoId(): ?int
+    {
+        return $this->matomoId;
+    }
+
+    /**
+     * @param int|null $matomoId
+     */
+    public function setMatomoId(?int $matomoId): void
+    {
+        $this->matomoId = $matomoId;
+    }
+
+    /**
+     * @return bool | null
+     */
+    public function isPremium(): ?bool
+    {
+        return $this->isPremium;
+    }
+
+    /**
+     * @param bool $isPremium
+     */
+    public function setIsPremium(bool $isPremium): void
+    {
+        $this->isPremium = $isPremium;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isDeleted(): bool
+    {
+        return $this->deleted;
+    }
+
+    /**
+     * @param bool $deleted
+     */
+    public function setDeleted(bool $deleted): void
+    {
+        $this->deleted = $deleted;
+    }
+
+    /**
+     * @return bool
+     */
+    public function isHiddenOrRestricted(): bool
+    {
+        return $this->hiddenOrRestricted;
+    }
+
+    /**
+     * @param bool $hiddenOrRestricted
+     */
+    public function setHiddenOrRestricted(bool $hiddenOrRestricted): void
+    {
+        $this->hiddenOrRestricted = $hiddenOrRestricted;
+    }
+
+    /**
+     * @return array
+     */
+    public function getMountedForBeUsers(): array
+    {
+        return $this->mountedForBeUsers;
+    }
+
+    /**
+     * @param array $mountedForBeUsers
+     */
+    public function setMountedForBeUsers(array $mountedForBeUsers): void
+    {
+        $this->mountedForBeUsers = $mountedForBeUsers;
+    }
+
+    /**
+     * @param array $beUser ['uid' => int, 'username' => string]
+     */
+    public function addMountedForBeUser(array $beUser): void
+    {
+        $this->mountedForBeUsers[] = $beUser;
+    }
+
+    /**
+     * @return array
+     */
+    public function getMountedForBeGroups(): array
+    {
+        return $this->mountedForBeGroups;
+    }
+
+    /**
+     * @param array $mountedForBeGroups
+     */
+    public function setMountedForBeGroups(array $mountedForBeGroups): void
+    {
+        $this->mountedForBeGroups = $mountedForBeGroups;
+    }
+
+    /**
+     * @param array $beGroup
+     */
+    public function addMountedForBeGroups(array $beGroup): void
+    {
+        $this->mountedForBeGroups[] = $beGroup;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getOwnerUserUid(): ?int
+    {
+        return $this->ownerUserUid;
+    }
+
+    /**
+     * @param int|null $ownerUserUid
+     */
+    public function setOwnerUserUid(?int $ownerUserUid): void
+    {
+        $this->ownerUserUid = $ownerUserUid;
+    }
+
+    /**
+     * @return int|null
+     */
+    public function getOwnerGroupUid(): ?int
+    {
+        return $this->ownerGroupUid;
+    }
+
+    /**
+     * @param int|null $ownerGroupUid
+     */
+    public function setOwnerGroupUid(?int $ownerGroupUid): void
+    {
+        $this->ownerGroupUid = $ownerGroupUid;
+    }
+}

+ 216 - 0
ot_admin/Classes/Domain/Entity/SiteStatus.php

@@ -0,0 +1,216 @@
+<?php
+
+namespace Opentalent\OtAdmin\Domain\Entity;
+
+/**
+ * Status of a website as returned by SiteController->getSiteStatusAction()
+ */
+class SiteStatus
+{
+    // Websites statuses
+    const STATUS_UNKNOWN = 0;
+    const STATUS_NO_SUCH_WEBSITE = -1;
+    const STATUS_EXISTING = 5;
+    const STATUS_EXISTING_DELETED = 2;
+    const STATUS_EXISTING_HIDDEN = 3;
+    const STATUS_EXISTING_WITH_WARNINGS = 4;
+
+    const STATUSES = [
+        self::STATUS_UNKNOWN => 'Unknown',
+        self::STATUS_NO_SUCH_WEBSITE => 'Does not exist',
+        self::STATUS_EXISTING => 'Existing',
+        self::STATUS_EXISTING_DELETED => 'Existing, deleted',
+        self::STATUS_EXISTING_HIDDEN => 'Existing, hidden',
+        self::STATUS_EXISTING_WITH_WARNINGS => 'Existing with warnings',
+    ];
+
+    /**
+     * Id of the organization owning the website
+     * @var int
+     */
+    protected int $organizationId;
+
+    /**
+     * Status-code of the website
+     * @var int
+     */
+    protected int $statusCode = self::STATUS_UNKNOWN;
+
+    /**
+     * SiteInfos of the website
+     *
+     * @var SiteInfos
+     */
+    protected SiteInfos $siteInfos;
+
+    /**
+     * Optional list of warnings
+     *
+     * @var array|null
+     */
+    protected ?array $warnings = null;
+
+    public function __construct(
+        int $organizationId,
+        $statusCode = null,
+        $siteInfos = null,
+        $warnings = null
+    )
+    {
+        $this->organizationId = $organizationId;
+        if ($statusCode !== null) {
+            $this->setStatusCode($statusCode);
+        }
+        if ($siteInfos !== null) {
+            $this->setSiteInfos($siteInfos);
+        }
+        if ($warnings !== null) {
+            $this->setWarnings($warnings);
+        }
+    }
+
+    /**
+     * Return the site status as a serializable array
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        $infos = [
+            "Organization Id" => $this->getOrganizationId(),
+            "Status" => $this->getStatusLabel(),
+            "Message" => $this->getMessage()
+        ];
+
+        if ($this->getStatusCode() > 0) {
+            $infos["Root Uid"] = $this->getSiteInfos()->getRootUid();
+            $infos["Site's title"] = $this->getSiteInfos()->getSiteTitle();
+            $infos["Base Url"] = $this->getSiteInfos()->getBaseUrl();
+            $infos["Deleted"] = (int)$this->getSiteInfos()->isDeleted();
+            $infos["Hidden or restricted"] = (int)$this->getSiteInfos()->isHiddenOrRestricted();
+            $infos["Premium"] = (int)$this->getSiteInfos()->isPremium();
+            $infos["Template"] = $this->getSiteInfos()->getTemplate();
+            $infos["Preferences"] = $this->getSiteInfos()->getPreferences();
+            $infos["Matomo id"] = $this->getSiteInfos()->getMatomoId() ?? "-";
+            $infos["Mounted for user(s): "] = $this->getSiteInfos()->getMountedForBeUsers();
+            $infos["Mounted for group(s): "] = $this->getSiteInfos()->getMountedForBeGroups();
+
+            if ($this->useWarnings()) {
+                $infos["Warnings"] = $this->getWarnings();
+            }
+        }
+        return $infos;
+    }
+
+    /**
+     * @return int
+     */
+    public function getOrganizationId(): int
+    {
+        return $this->organizationId;
+    }
+
+    /**
+     * @return int
+     */
+    public function getStatusCode(): int
+    {
+        return $this->statusCode;
+    }
+
+    /**
+     * @return string
+     */
+    public function getStatusLabel(): string
+    {
+        return self::STATUSES[$this->statusCode];
+    }
+
+    /**
+     * @return string
+     */
+    public function getMessage(): string
+    {
+        if($this->getStatusCode() == self::STATUS_NO_SUCH_WEBSITE) {
+            return 'No website were found for organization ' . $this->getOrganizationId();
+        }
+        elseif ($this->getStatusCode() == self::STATUS_EXISTING) {
+            return 'A website exists for organization ' . $this->getOrganizationId();
+        }
+        elseif ($this->getStatusCode() == self::STATUS_EXISTING_DELETED) {
+            return 'A website exists for organization ' . $this->getOrganizationId() . ', but has been deleted';
+        }
+        elseif ($this->getStatusCode() == self::STATUS_EXISTING_HIDDEN) {
+            return 'A website exists for organization ' . $this->getOrganizationId() . ', but is hidden or has a restricted access';
+        } elseif ($this->getStatusCode() == self::STATUS_EXISTING_WITH_WARNINGS) {
+            return 'A website exists for organization ' . $this->getOrganizationId() . ', but warnings were raised';
+        }
+    }
+
+    /**
+     * @param int $statusCode
+     */
+    public function setStatusCode(int $statusCode): void
+    {
+        if (!isset(self::STATUSES[$statusCode])) {
+            throw new \InvalidArgumentException('Invalid status code : ' . $statusCode);
+        }
+        $this->statusCode = $statusCode;
+    }
+
+    /**
+     * @return SiteInfos
+     */
+    public function getSiteInfos(): SiteInfos
+    {
+        return $this->siteInfos;
+    }
+
+    /**
+     * @param SiteInfos $siteInfos
+     */
+    public function setSiteInfos(SiteInfos $siteInfos): void
+    {
+        $this->siteInfos = $siteInfos;
+    }
+
+    /**
+     * @return array|null
+     */
+    public function getWarnings(): ?array
+    {
+        return $this->warnings;
+    }
+
+    /**
+     * @param array $warnings
+     */
+    public function setWarnings(array $warnings): void
+    {
+        $this->warnings = $warnings;
+    }
+
+    /**
+     * Warnings have been scanned (the warnings array still can be empty)
+     */
+    public function useWarnings(): bool
+    {
+        return $this->warnings !== null;
+    }
+
+    /**
+     * Warnings have been logged
+     */
+    public function hasWarnings(): bool
+    {
+        return count($this->warnings) > 0;
+    }
+
+    /**
+     * @param string $warning
+     */
+    public function addWarnings(string $warning): void
+    {
+        $this->warnings[] = $warning;
+    }
+}

+ 197 - 15
ot_admin/Classes/Http/ApiController.php

@@ -3,13 +3,20 @@
 namespace Opentalent\OtAdmin\Http;
 
 
+use Opentalent\OtAdmin\Controller\ScanController;
 use Opentalent\OtAdmin\Controller\SiteController;
 use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerAwareTrait;
-use TYPO3\CMS\Core\Core\Bootstrap;
 use TYPO3\CMS\Core\Http\JsonResponse;
 use TYPO3\CMS\Core\Http\ServerRequest;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
 
+/**
+ * Actions for Http API calls
+ *
+ * @package Opentalent\OtAdmin\Http
+ */
 class ApiController implements LoggerAwareInterface
 {
     use LoggerAwareTrait;
@@ -17,7 +24,11 @@ class ApiController implements LoggerAwareInterface
     const ALLOWED_IPS = [
         '/^127\.0\.0\.[0-1]$/',
         '/^localhost$/',
-        '/^10\.8\.0\.\d{1,3}$/'
+        '/^10\.8\.0\.\d{1,3}$/',
+        '/^80\.245\.24\.68$/',   // prod-front
+        '/^80\.245\.24\.70$/',   // prod-back
+        '/^80\.245\.24\.72$/',   // test
+        '/^80\.245\.24\.74$/'    // preprod
     ];
 
     /**
@@ -26,7 +37,8 @@ class ApiController implements LoggerAwareInterface
      * @param string $clientIp
      * @return bool
      */
-    public static function isIpAllowed(string $clientIp) {
+    public static function isIpAllowed(string $clientIp): bool
+    {
         foreach (self::ALLOWED_IPS as $ipRule) {
             if (preg_match($ipRule, $clientIp)) {
                 return true;
@@ -40,7 +52,8 @@ class ApiController implements LoggerAwareInterface
      *
      * @return bool
      */
-    private function assertIpAllowed() {
+    private function assertIpAllowed(): bool
+    {
         $clientIp = $_SERVER['REMOTE_ADDR'];
         if (!self::isIpAllowed($clientIp)){
             $route = $_REQUEST['route'];
@@ -58,7 +71,8 @@ class ApiController implements LoggerAwareInterface
      * @param ServerRequest $request
      * @return int
      */
-    private function getOrganizationId(ServerRequest $request) {
+    private function getOrganizationId(ServerRequest $request): int
+    {
         $params = $request->getQueryParams();
         $organizationId = $params['organization-id'];
         if (!$organizationId) {
@@ -67,6 +81,28 @@ class ApiController implements LoggerAwareInterface
         return (int)$organizationId;
     }
 
+    /**
+     * -- Target of the route 'site_infos' --
+     *
+     * Return the main informations about the organization's website
+     *
+     * @param ServerRequest $request
+     * @return JsonResponse
+     * @throws \Exception
+     */
+    public function getSiteInfosAction(ServerRequest $request): JsonResponse
+    {
+        $this->assertIpAllowed();
+
+        $organizationId = $this->getOrganizationId($request);
+
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+
+        $infos = $controller->getSiteInfosAction($organizationId);
+
+        return new JsonResponse($infos);
+    }
+
     /**
      * -- Target of the route 'site_create' --
      * >> Requires a query param named 'organization-id' (int)
@@ -77,12 +113,13 @@ class ApiController implements LoggerAwareInterface
      * @return JsonResponse
      * @throws \Exception
      */
-    public function createSiteAction(ServerRequest $request) {
+    public function createSiteAction(ServerRequest $request): JsonResponse
+    {
         $this->assertIpAllowed();
 
         $organizationId = $this->getOrganizationId($request);
 
-        $controller = new SiteController();
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
         $rootUid = $controller->createSiteAction($organizationId);
 
         $this->logger->info(sprintf(
@@ -108,13 +145,15 @@ class ApiController implements LoggerAwareInterface
      * @return JsonResponse
      * @throws \Exception
      */
-    public function updateSiteConstantsAction(ServerRequest $request) {
+    public function updateSiteConstantsAction(ServerRequest $request): JsonResponse
+    {
         $this->assertIpAllowed();
 
         $organizationId = $this->getOrganizationId($request);
+        $deep = (isset($queryParams['deep']) && $queryParams['deep']);
 
-        $controller = new SiteController();
-        $rootUid = $controller->updateSiteConstantsAction($organizationId);
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $rootUid = $controller->updateSiteAction($organizationId, $deep);
 
         $this->logger->info(sprintf(
             "OtAdmin API: The website with root uid " . $rootUid . " has been updated " .
@@ -139,13 +178,17 @@ class ApiController implements LoggerAwareInterface
      * @return JsonResponse
      * @throws \Exception
      */
-    public function deleteSiteAction(ServerRequest $request) {
+    public function deleteSiteAction(ServerRequest $request): JsonResponse
+    {
         $this->assertIpAllowed();
 
         $organizationId = $this->getOrganizationId($request);
 
-        $controller = new SiteController();
-        $rootUid = $controller->deleteSiteAction($organizationId, false);
+        $params = $request->getQueryParams();
+        $redirectTo = isset($params['redirect-to']) ? $params['redirect-to'] : null;
+
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $rootUid = $controller->deleteSiteAction($organizationId, false, $redirectTo);
 
         $this->logger->info(sprintf(
             "OtAdmin API: The website with root uid " . $rootUid . " has been soft-deleted " .
@@ -170,12 +213,13 @@ class ApiController implements LoggerAwareInterface
      * @return JsonResponse
      * @throws \Exception
      */
-    public function undeleteSiteAction(ServerRequest $request) {
+    public function undeleteSiteAction(ServerRequest $request): JsonResponse
+    {
         $this->assertIpAllowed();
 
         $organizationId = $this->getOrganizationId($request);
 
-        $controller = new SiteController();
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
         $rootUid = $controller->undeleteSiteAction($organizationId);
 
         $this->logger->info(sprintf(
@@ -190,4 +234,142 @@ class ApiController implements LoggerAwareInterface
             ]
         );
     }
+
+    /**
+     * -- Target of the route 'site_clearcache' --
+     * >> Requires a query param named 'organization-id' (int)
+     *
+     * Clear the cache of the organization's website
+     *
+     * @param ServerRequest $request
+     * @return JsonResponse
+     * @throws \Exception
+     */
+    public function clearSiteCacheAction(ServerRequest $request): JsonResponse
+    {
+        $this->assertIpAllowed();
+
+        $organizationId = $this->getOrganizationId($request);
+
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $rootUid = $controller->clearSiteCacheAction($organizationId);
+
+        return new JsonResponse(
+            [
+                'organization_id' => $organizationId,
+                'msg' => "The cache has been cleared for the website with root uid " . $rootUid . "",
+                'root_uid' => $rootUid
+            ]
+        );
+    }
+
+    /**
+     * -- Target of the route 'site_setdomain' --
+     * >> Requires a query param named 'organization-id' (int)
+     *
+     * Set a new domain for the organization website
+     *
+     * @param ServerRequest $request
+     * @return JsonResponse
+     * @throws \Exception
+     */
+    public function setSiteCustomDomainAction(ServerRequest $request): JsonResponse
+    {
+        $this->assertIpAllowed();
+
+        $organizationId = $this->getOrganizationId($request);
+
+        $queryParams = $request->getQueryParams();
+        $domain = $queryParams['domain'];
+        if (!$domain) {
+            throw new \RuntimeException("Missing 'domain' parameter");
+        }
+        $redirect = (isset($queryParams['redirect']) && $queryParams['redirect']);
+
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $rootUid = $controller->setSiteCustomDomainAction($organizationId, $domain, $redirect);
+
+        return new JsonResponse(
+            [
+                'organization_id' => $organizationId,
+                'msg' => "The cache has been cleared for the website with root uid " . $rootUid . "",
+                'root_uid' => $rootUid
+            ]
+        );
+    }
+
+    /**
+     * -- Target of the route 'site_resetperms' --
+     * >> Requires a query param named 'organization-id' (int)
+     *
+     * Reset the permissions of the website be users (admin, editors...)
+     *
+     * @param ServerRequest $request
+     * @return JsonResponse
+     * @throws \Exception
+     */
+    public function resetBeUserPermsAction(ServerRequest $request): JsonResponse
+    {
+        $this->assertIpAllowed();
+
+        $organizationId = $this->getOrganizationId($request);
+
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+        $rootUid = $controller->resetBeUserPermsAction($organizationId);
+
+        return new JsonResponse(
+            [
+                'organization_id' => $organizationId,
+                'msg' => "The website with root uid " . $rootUid . " had its be users permissions reset",
+                'root_uid' => $rootUid
+            ]
+        );
+    }
+
+    /**
+     * -- Target of the route 'site_status' --
+     * >> Requires a query param named 'organization-id' (int)
+     *
+     * Returns the current status of the website
+     *
+     * @param ServerRequest $request
+     * @return JsonResponse
+     * @throws \Exception
+     */
+    public function getSiteStatusAction(ServerRequest $request): JsonResponse
+    {
+        $this->assertIpAllowed();
+
+        $organizationId = $this->getOrganizationId($request);
+
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(SiteController::class);
+
+        $queryParams = $request->getQueryParams();
+        $full = (isset($queryParams['full']) && $queryParams['full']);
+        $status = $controller->getSiteStatusAction($organizationId, $full);
+
+        return new JsonResponse($status->toArray());
+    }
+
+    /**
+     * -- Target of the route 'scan' --
+     *
+     * Scan the whole Typo3 database and return the results
+     *
+     * @param ServerRequest $request
+     * @return JsonResponse
+     * @throws \Exception
+     */
+    public function scanAllAction(ServerRequest $request): JsonResponse
+    {
+        $this->assertIpAllowed();
+
+        $controller = GeneralUtility::makeInstance(ObjectManager::class)->get(ScanController::class);
+
+        $queryParams = $request->getQueryParams();
+        $full = (isset($queryParams['full']) && $queryParams['full']);
+        $results = $controller->scanAllAction($full);
+
+        return new JsonResponse($results);
+    }
 }

+ 1 - 1
ot_admin/Classes/Middleware/OtBackendUserAuthenticator.php

@@ -16,7 +16,7 @@ class OtBackendUserAuthenticator extends BackendUserAuthenticator
      * Check if the user is required for the request
      * If we're trying to do a login or an ajax login, don't require a user
      *
-     * @param string $routePath the Route path to check against, something like '
+     * @param string $routePath the Route path to check against
      * @return bool whether the request can proceed without a login required
      */
     protected function isLoggedInBackendUserRequired(string $routePath): bool

+ 51 - 21
ot_admin/Configuration/Backend/Routes.php

@@ -6,25 +6,55 @@ use Opentalent\OtAdmin\Http\ApiController;
 // @see https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/BackendRouting/Index.html
 
 return [
-        // Create a new organization's website
-        'site_create' => [
-            'path' => '/otadmin/site/create',
-            'target' => ApiController::class . '::createSiteAction',
-            'access' => 'public'
-        ],
-        'site_delete' => [
-            'path' => '/otadmin/site/delete',
-            'target' => ApiController::class . '::deleteSiteAction',
-            'access' => 'public'
-        ],
-        'site_undelete' => [
-            'path' => '/otadmin/site/undelete',
-            'target' => ApiController::class . '::undeleteSiteAction',
-            'access' => 'public'
-        ],
-        'site_update' => [
-            'path' => '/otadmin/site/update',
-            'target' => ApiController::class . '::updateSiteConstantsAction',
-            'access' => 'public'
-        ],
+    // Create a new organization's website
+    'site_create' => [
+        'path' => '/otadmin/site/create',
+        'target' => ApiController::class . '::createSiteAction',
+        'access' => 'public'
+    ],
+    'site_delete' => [
+        'path' => '/otadmin/site/delete',
+        'target' => ApiController::class . '::deleteSiteAction',
+        'access' => 'public'
+    ],
+    'site_undelete' => [
+        'path' => '/otadmin/site/undelete',
+        'target' => ApiController::class . '::undeleteSiteAction',
+        'access' => 'public'
+    ],
+    'site_update' => [
+        'path' => '/otadmin/site/update',
+        'target' => ApiController::class . '::updateSiteConstantsAction',
+        'access' => 'public'
+    ],
+    'site_clearcache' => [
+        'path' => '/otadmin/site/clear-cache',
+        'target' => ApiController::class . '::clearSiteCacheAction',
+        'access' => 'public'
+    ],
+    'site_setdomain' => [
+        'path' => '/otadmin/site/set-domain',
+        'target' => ApiController::class . '::setSiteCustomDomainAction',
+        'access' => 'public'
+    ],
+    'site_resetperms' => [
+        'path' => '/otadmin/site/reset-perms',
+        'target' => ApiController::class . '::resetBeUserPermsAction',
+        'access' => 'public'
+    ],
+    'site_index' => [
+        'path' => '/otadmin/site/index',
+        'target' => ApiController::class . '::updateRoutingIndexAction',
+        'access' => 'public'
+    ],
+    'site_status' => [
+        'path' => '/otadmin/site/status',
+        'target' => ApiController::class . '::getSiteStatusAction',
+        'access' => 'public'
+    ],
+    'scan' => [
+        'path' => '/otadmin/scan',
+        'target' => ApiController::class . '::scanAllAction',
+        'access' => 'public'
+    ]
 ];

+ 24 - 0
ot_admin/Configuration/Commands.php

@@ -21,6 +21,30 @@ return [
     ],
     'ot:site:update' => [
         'class' => Opentalent\OtAdmin\Command\UpdateSiteCommand::class
+    ],
+    'ot:site:clear-cache' => [
+        'class' => Opentalent\OtAdmin\Command\ClearSiteCacheCommand::class
+    ],
+    'ot:site:setdomain' => [
+        'class' => Opentalent\OtAdmin\Command\SetSiteDomainCommand::class
+    ],
+    'ot:site:reset-perms' => [
+        'class' => Opentalent\OtAdmin\Command\ResetBeUserPermsCommand::class
+    ],
+    'ot:site:index' => [
+        'class' => Opentalent\OtAdmin\Command\UpdateRoutingIndexCommand::class
+    ],
+    'ot:site:status' => [
+        'class' => Opentalent\OtAdmin\Command\GetSiteStatusCommand::class
+    ],
+    'ot:redirection:add' => [
+        'class' => Opentalent\OtAdmin\Command\AddRedirectionCommand::class
+    ],
+    'ot:redirection:remove' => [
+        'class' => Opentalent\OtAdmin\Command\RemoveRedirectionCommand::class
+    ],
+    'ot:scan' => [
+        'class' => Opentalent\OtAdmin\Command\ScanCommand::class
     ]
 ];
 

+ 48 - 0
ot_admin/Configuration/Services.yaml

@@ -24,3 +24,51 @@ services:
       - name: 'ot:site:undelete'
         command: 'ot:site:undelete'
         schedulable: false
+
+  Opentalent\OtAdmin\Command\ClearSiteCacheCommand:
+    tags:
+      - name: 'ot:site:clear-cache'
+        command: 'ot:site:clear-cache'
+        schedulable: true
+
+  Opentalent\OtAdmin\Command\SetSiteDomainCommand:
+    tags:
+      - name: 'ot:site:setdomain'
+        command: 'ot:site:setdomain'
+        schedulable: true
+
+  Opentalent\OtAdmin\Command\ResetBeUserPermsCommand:
+    tags:
+      - name: 'ot:site:reset-perms'
+        command: 'ot:site:reset-perms'
+        schedulable: true
+
+  Opentalent\OtAdmin\Command\UpdateRoutingIndexCommand:
+    tags:
+      - name: 'ot:site:index'
+        command: 'ot:site:index'
+        schedulable: true
+
+  Opentalent\OtAdmin\Command\GetSiteStatusCommand:
+    tags:
+      - name: 'ot:site:status'
+        command: 'ot:site:status'
+        schedulable: false
+
+  Opentalent\OtAdmin\Command\AddRedirectionCommand:
+    tags:
+      - name: 'ot:redirection:add'
+        command: 'ot:redirection:add'
+        schedulable: false
+
+  Opentalent\OtAdmin\Command\RemoveRedirectionCommand:
+    tags:
+      - name: 'ot:redirection:remove'
+        command: 'ot:redirection:remove'
+        schedulable: false
+
+  Opentalent\OtAdmin\Command\ScanCommand:
+    tags:
+      - name: 'ot:scan'
+        command: 'ot:scan'
+        schedulable: true

+ 9 - 9
ot_admin/Readme.md

@@ -9,8 +9,6 @@ This extension provides commands available by the API, CLI, or a dedicated admin
 | Vendor | Opentalent |
 | Nom | OtAdmin |
 
-> Warning: this extension depends on the ot_templating extension
-
 ## CLI
 
 Pour exécuter une commande depuis la console 
@@ -43,10 +41,12 @@ Les adresses IP autorisées sont:
 
 Les commandes disponibles sont:
 
-|||
-|---|---|
-| Create a new organization | `<typo3_host>/typo3/index.php?route=/otadmin/site/create&organization-id=<organization_id>` |
-| Update an organization | `<typo3_host>/typo3/index.php?route=/otadmin/site/update&organization-id=<organization_id>` |
-| Soft-delete an organization | `<typo3_host>/typo3/index.php?route=/otadmin/site/delete&organization-id=<organization_id>` |
-| Restore a soft-deleted organization | `<typo3_host>/typo3/index.php?route=/otadmin/site/undelete&organization-id=<organization_id>` |
-
+||||
+|---|---|---|
+| site/create | Create a new organization | `<typo3_host>/typo3/index.php?route=/otadmin/site/create&organization-id=<organization_id>` |
+| site/update | Update an organization | `<typo3_host>/typo3/index.php?route=/otadmin/site/update&organization-id=<organization_id>` |
+| site/delete | Soft-delete an organization | `<typo3_host>/typo3/index.php?route=/otadmin/site/delete&organization-id=<organization_id>` |
+| site/undelete | Restore a soft-deleted organization | `<typo3_host>/typo3/index.php?route=/otadmin/site/undelete&organization-id=<organization_id>` |
+| site/clear-cache | Clear the website's cache | `<typo3_host>/typo3/index.php?route=/otadmin/site/clear-cache&organization-id=<organization_id>` |
+| site/status | Get the current status of the website | `<typo3_host>/typo3/index.php?route=/otadmin/site/status&organization-id=<organization_id>[&full=1]` |
+| scan | Scan the whole Typo3 DB | `<typo3_host>/typo3/index.php?route=/otadmin/scan[&full=1]` |

+ 2 - 1
ot_admin/composer.json

@@ -9,7 +9,8 @@
         }
     ],
     "require": {
-        "typo3/cms-core": "^9.5 || ^10.4"
+        "typo3/cms-core": "^9.5 || ^10.4",
+        "twig/twig": "^2.13"
     },
     "autoload": {
         "psr-4": {

+ 118 - 0
ot_admin/templates/scan_report.twig

@@ -0,0 +1,118 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>OtAdmin Scan Report</title>
+
+        <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.10.23/css/jquery.dataTables.css">
+
+        <script type="text/javascript" charset="utf8" src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
+        <script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/1.10.23/js/jquery.dataTables.js"></script>
+        <script type="text/javascript" charset="utf8" src="https://cdn.jsdelivr.net/npm/chart.js@2.8.0"></script>
+
+        <style>
+            body {
+                margin: 0 10%;
+            }
+            .summary, .details {
+                margin: 24px 0;
+            }
+            .chart-div {
+                height: 400px;
+            }
+
+        </style>
+    </head>
+    <body>
+
+        <h1>OtAdmin Scan Report</h1>
+
+        <div class="summary">
+            <div class="chart-div">
+                <canvas id="chart_stats" width="400" height="400"></canvas>
+            </div>
+        </div>
+
+        <div class="details">
+            <table id="results" class="display">
+                <thead>
+                    <tr>
+                        <th>Organization Id</th>
+                        <th>Status</th>
+                        <th>Root uid</th>
+                        <th>Site's title</th>
+                        <th>Url</th>
+                        <th>Warnings</th>
+                    </tr>
+                </thead>
+                <tbody>
+                {% for status in scan.results %}
+                    <tr>
+                        <td>{{ status.organizationId }}</td>
+                        <td>{{ status.statusLabel }}</td>
+                        {% if status.statusCode > 0 %}
+                            <td>{{ status.siteInfos.rootUid }}</td>
+                            <td>{{ status.siteInfos.siteTitle }}</td>
+                            <td>{{ status.siteInfos.baseUrl }}</td>
+                            {% if status.useWarnings %}
+                                <td><ul>{% for warning in status.warnings %}<li>{{ warning }}</li>{% endfor %}</ul></td>
+                            {% endif %}
+                        {% endif %}
+                    </tr>
+                {% endfor %}
+                </tbody>
+            </table>
+        </div>
+
+        <script>
+            $(document).ready( function () {
+                // Datatable
+                $('#results').DataTable();
+
+
+            } );
+
+            // Charts
+            var labels = [];
+            var values = [];
+            {% for lbl, val in scan.labelledStats %}
+            labels.push('{{ lbl }}');
+            values.push({{ val }});
+            {% endfor %}
+
+            {% autoescape 'js' %}
+            var ctx = document.getElementById('chart_stats').getContext('2d');
+            var myChart = new Chart(ctx, {
+                type: 'bar',
+                data: {
+                    labels: labels,
+                    datasets: [{
+                        label: 'Stats',
+                        data: values,
+                        backgroundColor: [
+                            'rgba(255, 99, 132, 0.2)',
+                            'rgba(54, 162, 235, 0.2)',
+                            'rgba(255, 206, 86, 0.2)',
+                            'rgba(75, 192, 192, 0.2)',
+                            'rgba(153, 102, 255, 0.2)',
+                            'rgba(255, 159, 64, 0.2)'
+                        ],
+                        borderColor: [
+                            'rgba(255, 99, 132, 1)',
+                            'rgba(54, 162, 235, 1)',
+                            'rgba(255, 206, 86, 1)',
+                            'rgba(75, 192, 192, 1)',
+                            'rgba(153, 102, 255, 1)',
+                            'rgba(255, 159, 64, 1)'
+                        ],
+                        borderWidth: 1
+                    }]
+                },
+                options: {
+                    responsive: true,
+                    maintainAspectRatio: false
+                }
+            });
+            {% endautoescape %}
+        </script>
+    </body>
+</html>

+ 2 - 0
ot_connect/Classes/Middleware/RequestHandler.php

@@ -15,6 +15,8 @@ use TYPO3\CMS\Core\Http\RedirectResponse;
  */
 class RequestHandler implements MiddlewareInterface
 {
+    public function __construct() {}
+
     /**
      *
      * @param ServerRequestInterface $request

+ 35 - 84
ot_connect/Classes/Service/OtAuthenticationService.php

@@ -74,14 +74,24 @@ class OtAuthenticationService extends AbstractAuthenticationService
      * @see http://docs.guzzlephp.org/en/stable/
      * @var Client
      */
-    private $client;
+    private Client $client;
 
     /**
      * Guzzle Cookie Jar
      *
      * @var CookieJar
      */
-    private $jar;
+    private CookieJar $jar;
+
+    /**
+     * @var \TYPO3\CMS\Core\Database\ConnectionPool
+     */
+    private $connectionPool;
+
+    public function injectConnectionPool(ConnectionPool $connectionPool)
+    {
+        $this->connectionPool = $connectionPool;
+    }
 
     /**
      * OtAuthenticationService constructor.
@@ -89,6 +99,7 @@ class OtAuthenticationService extends AbstractAuthenticationService
     public function __construct() {
         $this->jar = new CookieJar;
         $this->client = new Client(['base_uri' => self::DOMAIN, 'cookies' => $this->jar]);
+        $this->connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
     }
 
     /**
@@ -98,6 +109,7 @@ class OtAuthenticationService extends AbstractAuthenticationService
      * @see https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Authentication/Index.html#the-auth-services-api
      *
      * @return array|bool User record or false (content of fe_users/be_users as appropriate for the current mode)
+     * @throws GuzzleException
      */
     public function getUser()
     {
@@ -135,7 +147,7 @@ class OtAuthenticationService extends AbstractAuthenticationService
 
         // Request the latest data for the user and write it in the Typo3 DB
         //   * The shouldUserBeUpdated() method checks if the user was already
-        //   generated in the last minutes, to avoid unecessary operations *
+        //   generated in the last minutes, to avoid unnecessary operations *
         if ($this->shouldUserBeUpdated($username)) {
             $wasUpdated = $this->createOrUpdateUser();
             if (!$wasUpdated) {
@@ -160,8 +172,8 @@ class OtAuthenticationService extends AbstractAuthenticationService
      * @return string|null
      * @throws GuzzleException
      */
-    protected function getAuthenticatedUsername() {
-
+    protected function getAuthenticatedUsername(): ?string
+    {
         $this->fillCookieJar();
         try {
             $response = $this->client->request('GET', self::ISAUTH_URI, ['cookies' => $this->jar]);
@@ -204,7 +216,8 @@ class OtAuthenticationService extends AbstractAuthenticationService
      * @return bool     Returns true if the api accepted the login request
      * @throws GuzzleException
      */
-    protected function logUser($username, $password) {
+    protected function logUser(string $username, string $password): bool
+    {
 
         try {
             $response = $this->client->request(
@@ -257,10 +270,11 @@ class OtAuthenticationService extends AbstractAuthenticationService
      * @param string $username
      * @return bool
      */
-    protected function shouldUserBeUpdated($username) {
+    protected function shouldUserBeUpdated(string $username): bool
+    {
 
-        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('fe_users');
-        $q = $connection->select(['tx_opentalent_generationDate'], 'fe_users', ['username' => $username]);
+        $cnn = $this->connectionPool->getConnectionForTable('fe_users');
+        $q = $cnn->select(['tx_opentalent_generationDate'], 'fe_users', ['username' => $username]);
         $strGenDate = $q->fetch(3)[0];
 
         $genDate = DateTime::createFromFormat("Y-m-d H:i:s", $strGenDate);
@@ -275,12 +289,12 @@ class OtAuthenticationService extends AbstractAuthenticationService
 
     /**
      * Create or update the Frontend-user record in the typo3 database (table 'fe_users')
-     * and the Backend-user (table 'be_users', only if is admin)
      * with the data fetched from the Api
      *
      * @return bool
      */
-    protected function createOrUpdateUser() {
+    protected function createOrUpdateUser(): bool
+    {
 
         // Get user's data from the API
         $userApiData = $this->getUserData();
@@ -291,7 +305,7 @@ class OtAuthenticationService extends AbstractAuthenticationService
             return false;
         }
 
-        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('fe_users');
+        $connection = $this->connectionPool->getConnectionForTable('fe_users');
 
         // Since we don't want to store the password in the TYPO3 DB, we store a random string instead
         $randomStr = (new Random)->generateRandomHexString(20);
@@ -302,7 +316,8 @@ class OtAuthenticationService extends AbstractAuthenticationService
             'password' => $randomStr,
             'name' => $userApiData['name'],
             'first_name' => $userApiData['first_name'],
-            'description' => '[ATTENTION: enregistrement auto-généré, ne pas modifier directement] FE User',
+            'description' => '[Warning: auto-generated record, do not modify] FE User',
+            'usergroup' => 21,
             'deleted' => 0,
             'tx_opentalent_opentalentId' => $userApiData['id'],
             'tx_opentalent_generationDate' => date('Y/m/d H:i:s')
@@ -329,73 +344,6 @@ class OtAuthenticationService extends AbstractAuthenticationService
             $connection->update('fe_users', $fe_row, ['uid' => $uid]);
         }
 
-        // Back-end user: only if admin
-        foreach ($userApiData['accesses'] as $access) {
-
-            if ($access['admin_access'] == 'true') {
-
-                // get the site root of the user
-                $q = $connection->select(
-                    ['uid'],
-                    'pages',
-                    ['tx_opentalent_structure_id' => $access['organizationId'], 'is_siteroot' => 1]
-                );
-                $rootUid = $q->fetch(3)[0];
-
-                if (!$rootUid) {
-                    $this->writeLogMessage('ERROR: Unable to find the root page for user ' . $userApiData['username']);
-                }
-
-                // get the filemounts uids
-                $q = $connection->createQueryBuilder();
-                $q->select('uid')
-                    ->from('sys_filemounts')
-                    ->where("path LIKE '%user_upload/" . $access['organizationId'] . "/%'");
-                $res = $q->execute();
-                $rows = $res->fetchAll(3) ?: [];
-                $files = [];
-                foreach ($rows as $row) {
-                    $files[] = $row[0];
-                }
-
-                $be_row = [
-                    'username' => $userApiData['username'],
-                    'password' => $randomStr,
-                    'description' => '[ATTENTION: enregistrement auto-généré, ne pas modifier directement] BE Admin for ' . $access['subDomain'] . ' (id: ' . $access['id'] . ')',
-                    'deleted' => 0,
-                    'lang' => 'fr',
-                    'usergroup' => isset(self::PRODUCT_MAPPING[$access['product']]) ? self::PRODUCT_MAPPING[$access['product']] : 1,
-                    'db_mountpoints' => $rootUid,
-                    'file_mountPoints' => join(',', $files),
-                    'options' => 2,
-                    'file_permissions' => 'readFolder,writeFolder,addFolder,renameFolder,moveFolder,deleteFolder,readFile,writeFile,addFile,renameFile,replaceFile,moveFile,copyFile,deleteFile',
-                    'tx_opentalent_opentalentId' => $userApiData['id'],
-                    'tx_opentalent_organizationId' => $access['organizationId'],
-                    'tx_opentalent_generationDate' => date('Y/m/d H:i:s')
-                ];
-
-                $q = $connection->select(
-                    ['uid'],
-                    'be_users',
-                    ['username' => $userApiData['username']]
-                );
-                $row = $q->fetch(3);
-                $uid = $row[0];
-                $tx_opentalent_opentalentId = $row[1];
-
-                if (!$uid) {
-                    // No existing user: create
-                    $connection->insert('be_users', $be_row);
-                } else {
-                    // User exists: update
-                    if (!$tx_opentalent_opentalentId > 0) {
-                        $this->writeLogMessage('WARNING: BE user ' . $userApiData['username'] . ' has been replaced by an auto-generated version.');
-                    }
-                    $connection->update('be_users', $be_row, ['uid' => $uid]);
-                }
-            }
-        }
-
         return true;
     }
 
@@ -404,7 +352,8 @@ class OtAuthenticationService extends AbstractAuthenticationService
      *
      * @return array
      */
-    protected function getUserData() {
+    protected function getUserData(): array
+    {
         $this->fillCookieJar();
         try {
             $response = $this->client->request('GET', self::GET_USER_DATA_URI, ['cookies' => $this->jar]);
@@ -425,7 +374,7 @@ class OtAuthenticationService extends AbstractAuthenticationService
      * @return int        Code that shows if user is really authenticated.
      * @throws GuzzleException
      */
-    public function authUser(array $user)
+    public function authUser(array $user): int
     {
         if ($user['username'] == $this->getAuthenticatedUsername()) {
             // Tha API just validated this user's auth
@@ -448,7 +397,8 @@ class OtAuthenticationService extends AbstractAuthenticationService
      * Send a logout request to the API, remove the sessions cookies then logout
      * /!\ Frontend only
      */
-    public function logout() {
+    public function logout(): bool
+    {
         try {
             $response = $this->client->request(
                 'GET',
@@ -487,7 +437,8 @@ class OtAuthenticationService extends AbstractAuthenticationService
      * @param string $name
      * @return bool
      */
-    protected function unset_cookie(string $name) {
+    protected function unset_cookie(string $name): bool
+    {
         $res = setcookie($name, '', time() - 1, '/', self::COOKIE_DOMAIN);
         if (!$res) {
             $this->writeLogMessage('Error while unsetting ' . $name . ' cookie');

+ 2 - 102
ot_connect/Readme.md

@@ -9,106 +9,6 @@ Extension d'authentification typo3.
 | Nom | OtConnect |
 
 Le rôle de cette extension est de fournir une authentification et une session unique pour les utilisateurs Opentalent, 
-qu'ils se rendent sur l'application Opentalent, sur le frontend du site de leur(s) structure(s), ou sur le backend TYPO3 
-(s'ils sont administrateurs du site de la structure).
-OtConnect se positionne en amont des services d'authentification Typo3 et utilise l'API Opentalent.
-En somme, un utilisateur connecté sur Opentalent.fr le sera aussi sur le ou les autres sous-domaines TYPO3 
-(correspondant à ses structures et à ses droits)
+qu'ils se rendent sur l'application Opentalent ou sur le frontend du site de leur(s) structure(s).
 
-## Fonctionnement de l'authentification TYPO3
-
-### Service d'authentification
-
-Pour authentifier un utilisateur, TYPO3 exécute des services par ordre de priorité, jusqu'à ce qu'un de ces services valident l'identité
-de l'utilisateur. Si aucun des services ne valident cette authentification, celle-ci est rejetée.
-
-### Création et enregistrement d'un service d'authentification
-
-Un service d'authentification doit hériter de la classe `TYPO3\CMS\Sv\AbstractAuthenticationService`, et implémenter au moins deux méthodes:
-
-* `getUser` vérifie qu'un utilisateur portant ce nom existe en base et retourne ses informations, ou retourne false en cas d'echec.
-* `authUser` vérifie que l'utilisateur est authentifié. La méthode retourne un code indiquant le résultat: 
-  * 0 signifie que l'authentification a échoué et que le process d'authentif doit s'arrêter là
-  * 100 signifie que l'authentification a échoué, mais que les services suivants peuvent essayer à leur tour d'authentifier le user
-  * 200 signifie que l'authentification a réussi
-
-De plus, le service doit être enregistré dans `ext_localconf.php` via la méthode `\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addService`.
-A cette étape, on peut lui donner des rôles (authentification backend et/ou frontend, par exemple).
-
-> **IMPORTANT** : quelle que soit la méthode d'authentification, les users backend et frontend 
->doivent avoir leurs enregistrements dans la base TYPO3 (tables `fe_users` et `be_users`)
-
-### Requêtes d'authentification
-
-Typo3 reconnait une requête d'authentification de la manière suivante :
-
-* La requête a un paramètre `logintype` dont la valeur est `login`: c'est une requête d'authentification Frontend
-* La requête a un paramètre `login_status` dont la valeur est `login`: c'est une requête d'authentification Backend
-
-Voilà les formulaires minimaux pour poster une demande d'authentification :
-
-    <-- FrontEnd -->
-    <form action="" method="POST" enctype="multipart/form-data" >
-        <input type="hidden" name="logintype" value="login" />
-        <input type="text" placeholder="Nom d'utilisateur" name="user" required="1" />
-        <input type="password" name="pass" placeholder="Mot de passe" required="1" />
-        <input type="submit" value="Se connecter" />
-    </form>
-
-    <-- BackEnd -->
-    <form action="" method="POST" enctype="multipart/form-data" >
-        <input type="hidden" name="login_status" value="login" />
-        <input type="text" placeholder="Nom d'utilisateur" name="username" required="1" />
-        <input type="password" name="password" placeholder="Mot de passe" required="1" />
-        <input type="submit" value="Se connecter" />
-    </form>
-
-> Côté Frontend, Typo3 attend deux champs dont les attributs 'name' sont 'user' et 'pass'. 
-
-### Base de données
-
-Les utilisateurs **Backend** doivent avoir une ligne correspondante dans la table `be_users` de la base TYPO3.
-Ils doivent avoir a minima les champs suivants renseignés : 
-
-* `username`
-* `password`: si le mot de passe n'est pas utilisé pour authentifier l'utilisateur, 
-par exemple parce qu'une API l'a déjà authentifié en amont, mettre une random string
-* `usergroup`: le user doit appartenir à un groupe existant (cf. `be_groups`), sauf s'il est admin (champs `admin` = 1)
-
-Les utilisateurs **Frontend** doivent avoir une ligne correspondante dans la table `fe_users` de la base TYPO3.
-Ils doivent avoir a minima les champs suivants renseignés : 
-
-* `username`
-* `password` : si le mot de passe n'est pas utilisé pour authentifier l'utilisateur, 
-par exemple parce qu'une API l'a déjà authentifié en amont, mettre une random string
-* `usergroup` : le user doit appartenir à un groupe existant (cf. `be_groups`)
-
-### Plus d'infos
-
-> [Voir la doc officielle](https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ApiOverview/Authentication/Index.html)
-
-
-## Fonctionnement de l'extension OtConnect
-
-Un service `OtAuthenticationService` est créé et enregistré avec les caractéristiques suivantes :
- 
-* `'subtype' => 'getUserFE,authUserFE,getUserBE,authUserBE'` : le service peut récupérer les infos des users 
-et les authentifier, à la fois pour le Frontend (FE) et pour le Backend (BE)
-* `'priority' => 80`: la priorité est fixée à 80, ce qui place le service en amont des services Typo3.
-
-Enfin, la variable de configuration `FE_fetchUserIfNoSession` force l'appel à la méthode getUser à chaque affichage d'une page frontEnd si une session n'existe pas déjà.
-(sans ça, l'utilisateur doit passer par la page de login même s'il a déjà une session opentalent.fr ouverte)
-
-Voilà les différents scénarios pour un utilisateur nommé Bob. 
-
-> Les cas suivants sont donnés pour le Frontend, mais ils sont identiques pour le Backend à deux différences près :
-> * Le user doit saisir son login / mdp (pas d'auto-log)
-> * Seuls les utilisateurs ayant la propriété `admin_access` à true ont accès au Backend.
-
-| Num. | Cas | Comportement |
-| --- | --- | --- |
-| 1 | Bob a une session Typo3-FE existante dans son navigateur (cf. cookie `fe_typo_user`) | Le service n'est pas appelé, Bob est déjà connecté |
-| 2 | Bob n'a pas de session Typo3 ouverte, mais il a une session ouverte dans son navigateur sur Opentalent.fr (cf. cookies `BEARER` et `SFSESSID`) | Une requête GET `/isauthenticated` est envoyée à l'API Opentalent. En cas de succès, une nouvelle requête est envoyée à l'API pour obtenir les données à jour de l'utilisateur, puis la ligne de Bob est créee ou mise à jour dans la table `fe_users` de la base Typo3 (sauf si cette mise à jour a déjà été faite dans les dernières minutes, voir const USER_UPDATE_DELAY) |
-| 3 | Bob n'a ni session Typo3-FE ni session Opentalent.fr ouverte, mais il a envoyé une requête de login valide | Ses données d'authentif sont envoyées à l'API qui lui ouvre une session. La suite se déroule comme pour le cas n°2 |
-| 4 | Bob n'a ni session Typo3-FE ni session Opentalent.fr ouverte, mais il a envoyé une requête de login invalide | Ses données d'authentif sont envoyées à l'API qui essaie de lui ouvrir une session et retourne un code d'echec. Le service, constatant qu'il s'agit tout de même d'un compte Opentalent, s'interrompt et refuse l'accès à Bob (qui pleure) |
-| 5 | Bob se connecte en utilisant un compte créé dans Typo3 ou via un autre service d'authentification (ce compte n'existe donc pas dans la base Opentalent)  | Ses données d'authentif sont envoyées à l'API qui essaie de lui ouvrir une session et retourne un code d'echec. Le service passe la main aux services Typo3 suivants par ordre de priorité |
+> Plus d'infos dans la [documentation](/doc/auth.md)

+ 7 - 0
ot_core/.gitignore

@@ -0,0 +1,7 @@
+.Build/
+.idea/
+Build/testing-docker/.env
+composer.lock
+/var/
+/coverage/
+/unittest-coverage.clover

+ 11 - 8
ot_core/Classes/Cache/OtCacheManager.php

@@ -2,18 +2,15 @@
 
 namespace Opentalent\OtCore\Cache;
 
-use Opentalent\OtCore\Page\OtPageRepository;
-use Psr\Http\Message\ResponseInterface;
-use RuntimeException;
+use Opentalent\OtCore\Website\OtPageRepository;
 use TYPO3\CMS\Core\Service\OpcodeCacheService;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
 use TYPO3\CMS\Install\Service\ClearCacheService;
-use TYPO3\CMS\Install\Service\Typo3tempFileService;
 use TYPO3\CMS\Core\Cache\CacheManager;
 
 class OtCacheManager
 {
-
     /**
      * Clears the page cache
      *
@@ -31,10 +28,11 @@ class OtCacheManager
      * Clears cache for each page of the site
      *
      * @param int $pageUid
+     * @param bool $clearAll if true, all caches will be cleared, and not only the frontend one
      */
-    public static function clearSiteCache(int $pageUid) {
+    public static function clearSiteCache(int $pageUid, $clearAll=false) {
 
-        $pageRepository = new OtPageRepository();
+        $pageRepository = GeneralUtility::makeInstance(ObjectManager::class)->get(OtPageRepository::class);
         $rootPage = $pageRepository->getRootPageFor($pageUid);
         $pages = $pageRepository->getAllSubpagesForPage($rootPage['uid']);
         $pages[] = $rootPage;
@@ -44,7 +42,12 @@ class OtCacheManager
         $tags = array_map(function ($page) {
             return 'pageId_' . $page['uid'];
         }, $pages);
-        $cacheManager->flushCachesInGroupByTags('pages', $tags);
+
+        if (!$clearAll) {
+            $cacheManager->flushCachesInGroupByTags('pages', $tags);
+        } else {
+            $cacheManager->flushCachesByTags($tags);
+        }
     }
 
     /**

+ 42 - 0
ot_core/Classes/Controller/ActionController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Opentalent\OtCore\Controller;
+
+use Opentalent\OtCore\Website\OtPageRepository;
+use Opentalent\OtCore\Website\OtWebsiteRepository;
+
+/**
+ * Base class for all controllers of backend modules
+ *
+ * Class ActionController
+ */
+class ActionController extends \TYPO3\CMS\Extbase\Mvc\Controller\ActionController
+{
+    /**
+     * @var OtWebsiteRepository
+     */
+    protected OtWebsiteRepository $otWebsiteRepository;
+
+    public function injectOtWebsiteRepository(OtWebsiteRepository $otWebsiteRepository) {
+        $this->otWebsiteRepository = $otWebsiteRepository;
+    }
+
+    /**
+     * @var OtPageRepository
+     */
+    protected OtPageRepository $otPageRepository;
+
+    public function injectOtPageRepository(OtPageRepository $otPageRepository) {
+        $this->otPageRepository = $otPageRepository;
+    }
+
+    /**
+     * Returns the language service.
+     *
+     * @return \TYPO3\CMS\Core\Localization\LanguageService
+     */
+    protected function getLanguageService()
+    {
+        return $GLOBALS['LANG'];
+    }
+}

+ 46 - 8
ot_core/Classes/Controller/SelectedSiteController.php

@@ -3,9 +3,8 @@
 namespace Opentalent\OtCore\Controller;
 
 use Opentalent\OtCore\Exception\NoSiteSelected;
-use Opentalent\OtCore\Page\OtPageRepository;
-use TYPO3\CMS\Core\Utility\GeneralUtility;
-use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
+use Opentalent\OtCore\Website\OtPageRepository;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
 
 /**
  * Base class for all controllers of backend modules that need
@@ -14,10 +13,19 @@ use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
  * If no page is selected or if this page does not have any root page as an ancestor,
  * the action is forwarded to a page asking the user to select one.
  *
+ * If only one website appears in the page-tree, this site is automatically selected
+ *
  * Class BeOnSiteController
  */
 class SelectedSiteController extends ActionController
 {
+    /**
+     * The current website
+     *
+     * @var array
+     */
+    protected $currentWebsite;
+
     /**
      * The current site root page uid
      *
@@ -25,14 +33,44 @@ class SelectedSiteController extends ActionController
      */
     protected $currentRootUid;
 
+    /** [FOR TESTS ONLY] */
+    protected $preventPropagation = false;
+
+    /**
+     *
+     * @throws \TYPO3\CMS\Extbase\Mvc\Exception\StopActionException
+     * @throws \Opentalent\OtCore\Exception\NoSuchWebsiteException
+     */
     protected function callActionMethod() {
-        try {
-            $otPageRepository = GeneralUtility::makeInstance(OtPageRepository::class);
-            $this->currentRootUid = $otPageRepository->getCurrentRootUid();
-        } catch (NoSiteSelected $e) {
-            $this->currentRootUid = null;
+
+        // Check if the current be-user has a db_mountpoint, and only has one.
+        // If so, this will be considered as the selected site (since its the only one available)
+        $mountpoints = $this->otPageRepository->getCurrentBeUserMountpoints();
+
+        if ($mountpoints && count($mountpoints) === 1) {
+            $this->currentRootUid = (int)$mountpoints[0];
+        } else {
+            // No db mountpoint has been set up, or more than one.
+            // Now we check if a site's page is selected
+            try {
+                if ($this->otPageRepository == null) {
+                    $objectManager = new ObjectManager();
+                    $this->otPageRepository = $objectManager->get(OtPageRepository::class);
+                }
+                $this->currentRootUid = $this->otPageRepository->getCurrentBeRootUid();
+            } catch (NoSiteSelected $e) {
+                $this->currentRootUid = null;
+            }
         }
 
+        if ($this->currentRootUid) {
+            $this->currentWebsite = $this->otWebsiteRepository->getWebsiteByPageUid($this->currentRootUid);
+        }
+
+        // [TESTS ONLY]
+        if ($this->preventPropagation) { return; }
+
+        // No site is selected, redirect to the warning page
         if ($this->actionMethodName != 'displayNoSelectedPageWarningAction' && $this->currentRootUid == null) {
             $this->forward('displayNoSelectedPageWarning', 'SelectedSite', 'OtCore');
         }

+ 23 - 23
ot_core/Classes/Domain/Model/Donor.php

@@ -74,7 +74,7 @@ class Donor extends AbstractEntity
     /**
      * @return int
      */
-    public function getId()
+    public function getId(): int
     {
         return $this->id;
     }
@@ -90,7 +90,7 @@ class Donor extends AbstractEntity
     /**
      * @return int
      */
-    public function getOrganizationId()
+    public function getOrganizationId(): int
     {
         return $this->organizationId;
     }
@@ -106,15 +106,15 @@ class Donor extends AbstractEntity
     /**
      * @return string
      */
-    public function getName()
+    public function getName(): ?string
     {
         return $this->name;
     }
 
     /**
-     * @param string $name
+     * @param string|null $name
      */
-    public function setName($name = '')
+    public function setName(?string $name)
     {
         $this->name = $name;
     }
@@ -122,15 +122,15 @@ class Donor extends AbstractEntity
     /**
      * @return string
      */
-    public function getEmail()
+    public function getEmail(): ?string
     {
         return $this->email;
     }
 
     /**
-     * @param string $email
+     * @param string|null $email
      */
-    public function setEmail($email = '')
+    public function setEmail(?string $email)
     {
         $this->email = $email;
     }
@@ -138,15 +138,15 @@ class Donor extends AbstractEntity
     /**
      * @return string
      */
-    public function getTelphone()
+    public function getTelphone(): ?string
     {
         return $this->telphone;
     }
 
     /**
-     * @param string $telphone
+     * @param string|null $telphone
      */
-    public function setTelphone($telphone = '')
+    public function setTelphone(?string $telphone)
     {
         $this->telphone = $telphone;
     }
@@ -154,15 +154,15 @@ class Donor extends AbstractEntity
     /**
      * @return string
      */
-    public function getWebsite()
+    public function getWebsite(): ?string
     {
         return $this->website;
     }
 
     /**
-     * @param string $website
+     * @param string|null $website
      */
-    public function setWebsite($website = '')
+    public function setWebsite(?string $website)
     {
         $this->website = $website;
     }
@@ -170,15 +170,15 @@ class Donor extends AbstractEntity
     /**
      * @return string
      */
-    public function getWording()
+    public function getWording(): ?string
     {
         return $this->wording;
     }
 
     /**
-     * @param string $wording
+     * @param string|null $wording
      */
-    public function setWording($wording = '')
+    public function setWording(?string $wording)
     {
         $this->wording = $wording;
     }
@@ -186,15 +186,15 @@ class Donor extends AbstractEntity
     /**
      * @return string
      */
-    public function getDisplayedOn()
+    public function getDisplayedOn(): ?string
     {
         return $this->displayedOn;
     }
 
     /**
-     * @param string $displayedOn
+     * @param string|null $displayedOn
      */
-    public function setDisplayedOn($displayedOn = '')
+    public function setDisplayedOn(?string $displayedOn)
     {
         $this->displayedOn = $displayedOn;
     }
@@ -202,15 +202,15 @@ class Donor extends AbstractEntity
     /**
      * @return string
      */
-    public function getLogo()
+    public function getLogo(): ?string
     {
         return $this->logo;
     }
 
     /**
-     * @param string $logo
+     * @param string|null $logo
      */
-    public function setLogo($logo = '')
+    public function setLogo(?string $logo)
     {
         $this->logo = $logo;
     }

Diferenças do arquivo suprimidas por serem muito extensas
+ 135 - 135
ot_core/Classes/Domain/Model/Event.php


+ 56 - 56
ot_core/Classes/Domain/Model/Member.php

@@ -152,7 +152,7 @@ class Member extends AbstractEntity
     /**
      * @return int
      */
-    public function getId()
+    public function getId(): int
     {
         return $this->id;
     }
@@ -168,7 +168,7 @@ class Member extends AbstractEntity
     /**
      * @return int
      */
-    public function getOrganizationId()
+    public function getOrganizationId(): int
     {
         return $this->organizationId;
     }
@@ -184,15 +184,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getGender()
+    public function getGender(): ?string
     {
         return $this->gender;
     }
 
     /**
-     * @param string $gender
+     * @param string|null $gender
      */
-    public function setGender($gender = '')
+    public function setGender(?string $gender)
     {
         $this->gender = $gender;
     }
@@ -200,15 +200,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getGivenName()
+    public function getGivenName(): ?string
     {
         return $this->givenName;
     }
 
     /**
-     * @param string $givenName
+     * @param string|null $givenName
      */
-    public function setGivenName($givenName = '')
+    public function setGivenName(?string $givenName)
     {
         $this->givenName = $givenName;
     }
@@ -216,15 +216,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getName()
+    public function getName(): ?string
     {
         return $this->name;
     }
 
     /**
-     * @param string $name
+     * @param string|null $name
      */
-    public function setName($name = '')
+    public function setName(?string $name)
     {
         $this->name = $name;
     }
@@ -232,15 +232,15 @@ class Member extends AbstractEntity
     /**
      * @return \Datetime
      */
-    public function getStartDate()
+    public function getStartDate(): ?\DateTime
     {
         return $this->startDate;
     }
 
     /**
-     * @param \Datetime $startDate
+     * @param \Datetime|null $startDate
      */
-    public function setStartDate(\Datetime $startDate)
+    public function setStartDate(?\Datetime $startDate)
     {
         $this->startDate = $startDate;
     }
@@ -248,15 +248,15 @@ class Member extends AbstractEntity
     /**
      * @return \Datetime
      */
-    public function getEndDate()
+    public function getEndDate(): ?\DateTime
     {
         return $this->endDate;
     }
 
     /**
-     * @param \Datetime $endDate
+     * @param \Datetime|null $endDate
      */
-    public function setEndDate(\Datetime $endDate)
+    public function setEndDate(?\Datetime $endDate)
     {
         $this->endDate = $endDate;
     }
@@ -264,15 +264,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getInstrumentGroup()
+    public function getInstrumentGroup(): ?string
     {
         return $this->instrumentGroup;
     }
 
     /**
-     * @param string $instrumentGroup
+     * @param string|null $instrumentGroup
      */
-    public function setInstrumentGroup($instrumentGroup = '')
+    public function setInstrumentGroup(?string $instrumentGroup)
     {
         $this->instrumentGroup = $instrumentGroup;
     }
@@ -280,15 +280,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getInstrument()
+    public function getInstrument(): ?string
     {
         return $this->instrument;
     }
 
     /**
-     * @param string $instrument
+     * @param string|null $instrument
      */
-    public function setInstrument($instrument = '')
+    public function setInstrument(?string $instrument)
     {
         $this->instrument = $instrument;
     }
@@ -296,15 +296,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getMission()
+    public function getMission(): ?string
     {
         return $this->mission;
     }
 
     /**
-     * @param string $mission
+     * @param string|null $mission
      */
-    public function setMission($mission = '')
+    public function setMission(?string $mission)
     {
         $this->mission = $mission;
     }
@@ -312,7 +312,7 @@ class Member extends AbstractEntity
     /**
      * @return int
      */
-    public function getPersonId()
+    public function getPersonId(): int
     {
         return $this->personId;
     }
@@ -328,15 +328,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getImage()
+    public function getImage(): ?string
     {
         return $this->image;
     }
 
     /**
-     * @param string $image
+     * @param string|null $image
      */
-    public function setImage($image = '')
+    public function setImage(?string $image)
     {
         $this->image = $image;
     }
@@ -344,15 +344,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getAdressCity()
+    public function getAddressCity(): ?string
     {
         return $this->addressCity;
     }
 
     /**
-     * @param string $addressCity
+     * @param string|null $addressCity
      */
-    public function setAdressCity($addressCity = '')
+    public function setAddressCity(?string $addressCity)
     {
         $this->addressCity = $addressCity;
     }
@@ -360,15 +360,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getStreetAdress()
+    public function getStreetAddress(): ?string
     {
         return $this->streetAddress;
     }
 
     /**
-     * @param string $streetAddress
+     * @param string|null $streetAddress
      */
-    public function setStreetAdress($streetAddress = '')
+    public function setStreetAddress(?string $streetAddress)
     {
         $this->streetAddress = $streetAddress;
     }
@@ -376,15 +376,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getStreetAdressSecond()
+    public function getStreetAddressSecond(): ?string
     {
         return $this->streetAddressSecond;
     }
 
     /**
-     * @param string $streetAddressSecond
+     * @param string|null $streetAddressSecond
      */
-    public function setStreetAdressSecond($streetAddressSecond = '')
+    public function setStreetAddressSecond(?string $streetAddressSecond)
     {
         $this->streetAddressSecond = $streetAddressSecond;
     }
@@ -392,15 +392,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getStreetAdressThird()
+    public function getStreetAddressThird(): ?string
     {
         return $this->streetAddressThird;
     }
 
     /**
-     * @param string $streetAddressThird
+     * @param string|null $streetAddressThird
      */
-    public function setStreetAdressThird($streetAddressThird = '')
+    public function setStreetAddressThird(?string $streetAddressThird)
     {
         $this->streetAddressThird = $streetAddressThird;
     }
@@ -408,15 +408,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getPostalCode()
+    public function getPostalCode(): ?string
     {
         return $this->postalCode;
     }
 
     /**
-     * @param string $postalCode
+     * @param string|null $postalCode
      */
-    public function setPostalCode($postalCode = '')
+    public function setPostalCode(?string $postalCode)
     {
         $this->postalCode = $postalCode;
     }
@@ -424,15 +424,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getTelphone()
+    public function getTelphone(): ?string
     {
         return $this->telphone;
     }
 
     /**
-     * @param string $telphone
+     * @param string|null $telphone
      */
-    public function setTelphone($telphone = '')
+    public function setTelphone(?string $telphone)
     {
         $this->telphone = $telphone;
     }
@@ -440,15 +440,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getMobilPhone()
+    public function getMobilPhone(): ?string
     {
         return $this->mobilPhone;
     }
 
     /**
-     * @param string $mobilPhone
+     * @param string|null $mobilPhone
      */
-    public function setMobilPhone($mobilPhone = '')
+    public function setMobilPhone(?string $mobilPhone)
     {
         $this->mobilPhone = $mobilPhone;
     }
@@ -456,15 +456,15 @@ class Member extends AbstractEntity
     /**
      * @return string
      */
-    public function getEmail()
+    public function getEmail(): ?string
     {
         return $this->email;
     }
 
     /**
-     * @param string $email
+     * @param string|null $email
      */
-    public function setEmail($email = '')
+    public function setEmail(?string $email)
     {
         $this->email = $email;
     }
@@ -474,11 +474,11 @@ class Member extends AbstractEntity
      *
      * @return string
      */
-    public function getFullName()
+    public function getFullName(): string
     {
         if ($this->getGivenName() && $this->getName()) {
             return $this->getGivenName() . ' ' . $this->getName();
-        } else if ($this->getName() && $this->getGender()) {
+        } else if ($this->getName()) {
             return 'M. ' . $this->getName();
         } else {
             return '';

+ 80 - 83
ot_core/Classes/Domain/Model/Organization.php

@@ -11,135 +11,135 @@ class Organization extends AbstractEntity
     /**
      * id
      *
-     * @var string
+     * @var int
      */
-    protected $id = '';
+    protected $id;
 
     /**
      * type
      *
      * @var string
      */
-    protected $type = '';
+    protected $type;
 
     /**
      * subDomain
      *
      * @var string
      */
-    protected $subDomain = '';
+    protected $subDomain;
 
     /**
      * name
      *
      * @var string
      */
-    protected $name = '';
+    protected $name;
 
     /**
      * slug
      *
      * @var string
      */
-    protected $slug = '';
+    protected $slug;
 
     /**
      * principalType
      *
      * @var string
      */
-    protected $principalType = '';
+    protected $principalType;
 
     /**
      * description
      *
      * @var string
      */
-    protected $description = '';
+    protected $description;
 
     /**
      * categories
      *
-     * @var int
+     * @var array
      */
-    protected $categories = 0;
+    protected $categories;
 
     /**
      * addressCity
      *
      * @var string
      */
-    protected $addressCity = '';
+    protected $addressCity;
 
     /**
      * postalCode
      *
      * @var string
      */
-    protected $postalCode = '';
+    protected $postalCode;
 
     /**
      * streetAdress
      *
      * @var string
      */
-    protected $streetAdress = '';
+    protected $streetAddress;
 
     /**
      * latitude
      *
      * @var float
      */
-    protected $latitude = 0.0;
+    protected $latitude;
 
     /**
      * longitude
      *
      * @var float
      */
-    protected $longitude = 0.0;
+    protected $longitude;
 
     /**
      * country
      *
      * @var string
      */
-    protected $country = '';
+    protected $country;
 
     /**
      * logo
      *
      * @var string
      */
-    protected $logo = '';
+    protected $logo;
 
     /**
      * parentId
      *
      * @var int
      */
-    protected $parentId = 0;
+    protected $parentId;
 
     /**
      * parentName
      *
      * @var string
      */
-    protected $parentName = '';
+    protected $parentName;
 
     /**
      * parentSubdomain
      *
      * @var string
      */
-    protected $parentSubdomain = '';
+    protected $parentSubdomain;
 
     /**
      * Returns the id
      *
-     * @return string $id
+     * @return int $id
      */
-    public function getId()
+    public function getId(): int
     {
         return $this->id;
     }
@@ -147,10 +147,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the id
      *
-     * @param string $id
+     * @param int $id
      * @return void
      */
-    public function setId($id)
+    public function setId(int $id)
     {
         $this->id = $id;
     }
@@ -160,7 +160,7 @@ class Organization extends AbstractEntity
      *
      * @return string $type
      */
-    public function getType()
+    public function getType(): ?string
     {
         return $this->type;
     }
@@ -168,10 +168,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the type
      *
-     * @param string $type
+     * @param string|null $type
      * @return void
      */
-    public function setType($type)
+    public function setType(?string $type)
     {
         $this->type = $type;
     }
@@ -181,7 +181,7 @@ class Organization extends AbstractEntity
      *
      * @return string $subDomain
      */
-    public function getSubDomain()
+    public function getSubDomain(): ?string
     {
         return $this->subDomain;
     }
@@ -189,10 +189,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the subDomain
      *
-     * @param string $subDomain
+     * @param string|null $subDomain
      * @return void
      */
-    public function setSubDomain($subDomain)
+    public function setSubDomain(?string $subDomain)
     {
         $this->subDomain = $subDomain;
     }
@@ -202,7 +202,7 @@ class Organization extends AbstractEntity
      *
      * @return string $name
      */
-    public function getName()
+    public function getName(): ?string
     {
         return $this->name;
     }
@@ -210,10 +210,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the name
      *
-     * @param string $name
+     * @param string|null $name
      * @return void
      */
-    public function setName($name)
+    public function setName(?string $name)
     {
         $this->name = $name;
     }
@@ -223,7 +223,7 @@ class Organization extends AbstractEntity
      *
      * @return string $slug
      */
-    public function getSlug()
+    public function getSlug(): ?string
     {
         return $this->slug;
     }
@@ -231,10 +231,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the slug
      *
-     * @param string $slug
+     * @param ?string $slug
      * @return void
      */
-    public function setSlug($slug)
+    public function setSlug(?string $slug)
     {
         $this->slug = $slug;
     }
@@ -244,7 +244,7 @@ class Organization extends AbstractEntity
      *
      * @return string $principalType
      */
-    public function getPrincipalType()
+    public function getPrincipalType(): ?string
     {
         return $this->principalType;
     }
@@ -252,10 +252,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the principalType
      *
-     * @param string $principalType
+     * @param string|null $principalType
      * @return void
      */
-    public function setPrincipalType($principalType)
+    public function setPrincipalType(?string $principalType)
     {
         $this->principalType = $principalType;
     }
@@ -265,7 +265,7 @@ class Organization extends AbstractEntity
      *
      * @return string $description
      */
-    public function getDescription()
+    public function getDescription(): ?string
     {
         return $this->description;
     }
@@ -273,10 +273,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the description
      *
-     * @param string $description
+     * @param string|null $description
      * @return void
      */
-    public function setDescription($description)
+    public function setDescription(?string $description)
     {
         $this->description = $description;
     }
@@ -284,9 +284,9 @@ class Organization extends AbstractEntity
     /**
      * Returns the categories
      *
-     * @return int $categories
+     * @return array|null $categories
      */
-    public function getCategories()
+    public function getCategories(): ?array
     {
         return $this->categories;
     }
@@ -294,10 +294,9 @@ class Organization extends AbstractEntity
     /**
      * Sets the categories
      *
-     * @param int $categories
-     * @return void
+     * @param array|null $categories
      */
-    public function setCategories($categories)
+    public function setCategories(?array $categories)
     {
         $this->categories = $categories;
     }
@@ -307,7 +306,7 @@ class Organization extends AbstractEntity
      *
      * @return string $addressCity
      */
-    public function getAddressCity()
+    public function getAddressCity(): ?string
     {
         return $this->addressCity;
     }
@@ -315,10 +314,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the addressCity
      *
-     * @param string $addressCity
+     * @param string|null $addressCity
      * @return void
      */
-    public function setAddressCity($addressCity)
+    public function setAddressCity(?string $addressCity)
     {
         $this->addressCity = $addressCity;
     }
@@ -328,7 +327,7 @@ class Organization extends AbstractEntity
      *
      * @return string $postalCode
      */
-    public function getPostalCode()
+    public function getPostalCode(): ?string
     {
         return $this->postalCode;
     }
@@ -336,33 +335,33 @@ class Organization extends AbstractEntity
     /**
      * Sets the postalCode
      *
-     * @param string $postalCode
+     * @param string|null $postalCode
      * @return void
      */
-    public function setPostalCode($postalCode)
+    public function setPostalCode(?string $postalCode)
     {
         $this->postalCode = $postalCode;
     }
 
     /**
-     * Returns the streetAdress
+     * Returns the streetAddress
      *
-     * @return string $streetAdress
+     * @return string $streetAddress
      */
-    public function getStreetAdress()
+    public function getStreetAddress(): ?string
     {
-        return $this->streetAdress;
+        return $this->streetAddress;
     }
 
     /**
      * Sets the streetAdress
      *
-     * @param string $streetAdress
+     * @param string|null $streetAddress
      * @return void
      */
-    public function setStreetAdress($streetAdress)
+    public function setStreetAddress(?string $streetAddress)
     {
-        $this->streetAdress = $streetAdress;
+        $this->streetAddress = $streetAddress;
     }
 
     /**
@@ -370,7 +369,7 @@ class Organization extends AbstractEntity
      *
      * @return float $latitude
      */
-    public function getLatitude()
+    public function getLatitude(): ?float
     {
         return $this->latitude;
     }
@@ -378,10 +377,9 @@ class Organization extends AbstractEntity
     /**
      * Sets the latitude
      *
-     * @param float $latitude
-     * @return void
+     * @param float|null $latitude
      */
-    public function setLatitude($latitude)
+    public function setLatitude(?float $latitude)
     {
         $this->latitude = $latitude;
     }
@@ -391,7 +389,7 @@ class Organization extends AbstractEntity
      *
      * @return float $longitude
      */
-    public function getLongitude()
+    public function getLongitude(): ?float
     {
         return $this->longitude;
     }
@@ -399,10 +397,9 @@ class Organization extends AbstractEntity
     /**
      * Sets the longitude
      *
-     * @param float $longitude
-     * @return void
+     * @param float|null $longitude
      */
-    public function setLongitude($longitude)
+    public function setLongitude(?float $longitude)
     {
         $this->longitude = $longitude;
     }
@@ -412,7 +409,7 @@ class Organization extends AbstractEntity
      *
      * @return string $country
      */
-    public function getCountry()
+    public function getCountry(): ?string
     {
         return $this->country;
     }
@@ -420,10 +417,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the country
      *
-     * @param string $country
+     * @param string|null $country
      * @return void
      */
-    public function setCountry($country)
+    public function setCountry(?string $country)
     {
         $this->country = $country;
     }
@@ -433,7 +430,7 @@ class Organization extends AbstractEntity
      *
      * @return string $logo
      */
-    public function getLogo()
+    public function getLogo(): ?string
     {
         return $this->logo;
     }
@@ -441,10 +438,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the logo
      *
-     * @param string $logo
+     * @param string|null $logo
      * @return void
      */
-    public function setLogo($logo)
+    public function setLogo(?string $logo)
     {
         $this->logo = $logo;
     }
@@ -454,7 +451,7 @@ class Organization extends AbstractEntity
      *
      * @return string $parentId
      */
-    public function getParentId()
+    public function getParentId(): ?int
     {
         return $this->parentId;
     }
@@ -462,10 +459,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the parentName
      *
-     * @param string $parentId
+     * @param int|null $parentId
      * @return void
      */
-    public function setParentId($parentId)
+    public function setParentId(?int $parentId)
     {
         $this->parentId = $parentId;
     }
@@ -475,7 +472,7 @@ class Organization extends AbstractEntity
      *
      * @return string $parentName
      */
-    public function getParentName()
+    public function getParentName(): ?string
     {
         return $this->parentName;
     }
@@ -483,10 +480,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the parentName
      *
-     * @param string $parentName
+     * @param string|null $parentName
      * @return void
      */
-    public function setParentName($parentName)
+    public function setParentName(?string $parentName)
     {
         $this->parentName = $parentName;
     }
@@ -496,7 +493,7 @@ class Organization extends AbstractEntity
      *
      * @return string $parentSubdomain
      */
-    public function getParentSubdomain()
+    public function getParentSubdomain(): ?string
     {
         return $this->parentSubdomain;
     }
@@ -504,10 +501,10 @@ class Organization extends AbstractEntity
     /**
      * Sets the parentSubdomain
      *
-     * @param string $parentSubdomain
+     * @param string|null $parentSubdomain
      * @return void
      */
-    public function setParentSubdomain($parentSubdomain)
+    public function setParentSubdomain(?string $parentSubdomain)
     {
         $this->parentSubdomain = $parentSubdomain;
     }

+ 89 - 22
ot_core/Classes/Domain/Repository/BaseApiRepository.php

@@ -8,42 +8,108 @@ use Opentalent\OtCore\Exception\ApiRequestException;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerAwareTrait;
+use Symfony\Component\Yaml\Exception\ParseException;
+use Symfony\Component\Yaml\Yaml;
+use TYPO3\CMS\Core\Core\ApplicationContext;
 use TYPO3\CMS\Extbase\Object\ObjectManagerInterface;
-use TYPO3\CMS\Extbase\Persistence\Repository;
 
 /**
  *  Base class for repositories based on the Opentalent API
  *
  */
-abstract class BaseApiRepository extends Repository implements LoggerAwareInterface
+abstract class BaseApiRepository implements LoggerAwareInterface
 {
     use LoggerAwareTrait;
 
-    const BASE_URI = 'https://api.opentalent.fr/api/';
+    const DEFAULT_BASE_URI = 'https://api.opentalent.fr/api/';
+    const URI_TRAILING_PART = '';
     const HYDRA_TYPE = '';
     const HTTP_METHOD = 'GET';
     const DEFAULT_ITEMS_PER_PAGE = 8;
 
-    private $client;
-    private $context;
+    protected string $base_uri = self::DEFAULT_BASE_URI;
+    protected array $variants_uris = [];
+    protected Client $client;
+    protected ApplicationContext $context;
 
-    public function __construct(ObjectManagerInterface $objectManager) {
-        parent::__construct($objectManager);
-        $this->client = new Client(['base_uri' => static::BASE_URI]);
-        $this->context = \TYPO3\CMS\Core\Core\Environment::getContext();
+    /**
+     * BaseApiRepository constructor.
+     *
+     * @param ObjectManagerInterface $objectManager
+     * @param Client|null $client  [For tests only]
+     * @param ApplicationContext|null $context  [For tests only]
+     */
+    public function __construct(
+        ?Client $client = null,
+        ?ApplicationContext $context = null
+    ) {
+        if ($context === null) {
+            $this->context = \TYPO3\CMS\Core\Core\Environment::getContext();
+        } else {
+            $this->context = $context;
+        }
+
+        $this->loadConf();
+
+        if ($client === null) {
+            $this->client = new Client(['base_uri' => $this->getApiUri()]);
+        } else {
+            $this->client = $client;
+        }
+    }
+
+    private function loadConf() {
+        $conf_path = $_ENV['TYPO3_PATH_ROOT'] . '/typo3conf/ext/ot_core/Configuration/ot_config.yaml';
+        $conf = Yaml::parseFile($conf_path);
+
+        // api_variant_uri: Should we set an alternative uri for the API? (dev and testing only)
+        if ($this->context->isDevelopment() || $this->context->isTesting()) {
+            $this->variants_uris = $conf['api_variant_uri'];
+        }
+    }
+
+    /**
+     * Return the API URI for the current repository
+     *
+     * @param string $trailing_part
+     * @return string
+     */
+    protected function getApiUri(string $trailing_part = null): string
+    {
+        $host = $_SERVER['HTTP_HOST'];
+        if (isset($this->variants_uris[$host])) {
+            $uri = $this->variants_uris[$host];
+        } else {
+            $uri = self::DEFAULT_BASE_URI;
+        }
+
+        $trailing_part = $trailing_part ?? $this::URI_TRAILING_PART;
+        $uri = trim($uri, '/') . '/' . trim($trailing_part, '/');
+        return $uri;
+    }
+
+    /**
+     * [FOR TESTS ONLY]
+     * @param Client $client
+     */
+    protected function injectClient(Client $client) {
+        $this->client = $client;
     }
 
     /**
      * Send a request to the API and
      * returns the records as an array (members)
      *
-     * @param string $uri
      * @param array $params
+     * @param string|null $forceUri
      * @return ApiPagedCollection
      * @throws ApiRequestException
      */
-    protected function getApiRecords(string $uri, $params = []) {
-        $body = $this->getJson($uri, $params);
+    protected function getApiRecords(array $params = [], ?string $forceUri = null): ApiPagedCollection
+    {
+        $uri = $forceUri ?? $this->getApiUri();
+
+        $body = $this->getJsonDecoded($uri, $params);
 
         $page = (int)($params['page'] ?? 1);
 
@@ -70,24 +136,25 @@ abstract class BaseApiRepository extends Repository implements LoggerAwareInterf
     /**
      * -- Needs to be reimplemented in subclasses --
      * Convert response's members record to an actual Domain's object
-     * @param array $member
+     * @param array $record
      * @return object
      */
-    abstract protected function memberToObject(array $member);
+    abstract protected function memberToObject(array $record);
 
     /**
      * Send a request to the API and
      * returns the first record (member)
      *
-     * @param string $uri
      * @param array $params
-     * @return array
+     * @param string|null $forceUri
+     * @return object
      * @throws ApiRequestException
      */
-    protected function getApiFirstRecord(string $uri, $params = []) {
+    protected function getApiFirstRecord($params = [], ?string $forceUri = null)
+    {
         $params['page'] = '1';
         $params['totalItems'] = '1';
-        $collection = $this->getApiRecords($uri, $params);
+        $collection = $this->getApiRecords($params, $forceUri);
         return $collection->getMembers()[0];
     }
 
@@ -100,7 +167,7 @@ abstract class BaseApiRepository extends Repository implements LoggerAwareInterf
      * @return array
      * @throws ApiRequestException
      */
-    protected function getJson(string $uri, $params = [])
+    protected function getJsonDecoded(string $uri, $params = []): array
     {
         return json_decode($this->getBody($uri, $params),true);
     }
@@ -116,7 +183,7 @@ abstract class BaseApiRepository extends Repository implements LoggerAwareInterf
      */
     protected function getBody(string $uri, $params = [])
     {
-        return (string)$this->get($uri, $params)->getBody();
+        return (string)$this->getResponse($uri, $params)->getBody();
     }
 
     /**
@@ -128,7 +195,7 @@ abstract class BaseApiRepository extends Repository implements LoggerAwareInterf
      * @return ResponseInterface
      * @throws ApiRequestException
      */
-    protected function get(string $uri, $params = [])
+    protected function getResponse(string $uri, $params = []): ResponseInterface
     {
         $uri = $uri . '?_format=json';
         if(!isset($params['itemsPerPage'])) {
@@ -138,7 +205,7 @@ abstract class BaseApiRepository extends Repository implements LoggerAwareInterf
             $uri = $uri . '&' . http_build_query($params);
         }
         try {
-            if (!$this->context->isProduction()) {
+            if ($this->context->isDevelopment()) {
                 $this->logger->info('API Call: ' . $uri);
             }
             return $this->client->request(static::HTTP_METHOD, $uri);

+ 3 - 3
ot_core/Classes/Domain/Repository/DonorRepository.php

@@ -7,7 +7,7 @@ use Opentalent\OtCore\Exception\ApiRequestException;
 
 class DonorRepository extends BaseApiRepository
 {
-    CONST URI = BaseApiRepository::BASE_URI . 'public/donors';
+    const URI_TRAILING_PART = 'public/donors';
     const HYDRA_TYPE = 'PortailDonor';
 
     /**
@@ -23,7 +23,7 @@ class DonorRepository extends BaseApiRepository
         $params['organizationId'] = $organizationId;
         $params['page'] = $page;
 
-        return $this->getApiRecords($this::URI, $params);
+        return $this->getApiRecords($params);
     }
 
     /**
@@ -40,7 +40,7 @@ class DonorRepository extends BaseApiRepository
         $params['parent'] = 1;
         $params['page'] = $page;
 
-        return $this->getApiRecords($this::URI, $params);
+        return $this->getApiRecords($params);
     }
 
     /**

+ 21 - 20
ot_core/Classes/Domain/Repository/EventRepository.php

@@ -2,26 +2,26 @@
 
 namespace Opentalent\OtCore\Domain\Repository;
 
-use DateTimeZone;
 use Exception;
 use Opentalent\OtCore\Domain\Model\Event;
 use Opentalent\OtCore\Exception\ApiRequestException;
 
 class EventRepository extends BaseApiRepository
 {
-    CONST URI = BaseApiRepository::BASE_URI . 'public/events';
+    const URI_TRAILING_PART = 'public/events';
     const HYDRA_TYPE = 'PortailEvent';
 
     /**
      * Get the event matching the given id
      *
      * @param int $id The id of the event
-     * @return array Event
+     * @return object Event
      * @throws ApiRequestException
      */
-    public function findById(int $id) {
-        $params = [];
-        return $this->getApiFirstRecord($this::URI . '/' . $id, $params);
+    public function findById(int $id): object
+    {
+        $params = ["filter[where][id]" => $id];
+        return $this->getApiFirstRecord($params);
     }
 
     /**
@@ -49,14 +49,14 @@ class EventRepository extends BaseApiRepository
         }
         if ($fromDate !== null) {
             // Inutile a priori: la view de l'API s'en occupe déjà en amont
-            $params['filter[where][datetimeEnd][gte]'] = $fromDate->format('c');
+            $params['filter[where][datetimeStart][gte]'] = $fromDate->format('c');
         }
         if ($toDate !== null) {
-            $params['filter[where][datetimeStart][lte]'] = $toDate->format('c');
+            $params['filter[where][datetimeEnd][lte]'] = $toDate->format('c');
         }
-        $params['filter[order][0][datetimeEnd]'] = 'ASC';
+        $params['filter[order][0][datetimeStart]'] = 'ASC';
 
-        return $this->getApiRecords($this::URI, $params);
+        return $this->getApiRecords($params);
     }
 
     /**
@@ -84,16 +84,16 @@ class EventRepository extends BaseApiRepository
         }
         if ($fromDate !== null) {
             // Inutile a priori: la view de l'API s'en occupe déjà en amont
-            $params['filter[where][datetimeEnd][gte]'] = $fromDate->format('c');
+            $params['filter[where][datetimeStart][gte]'] = $fromDate->format('c');
         }
         if ($toDate !== null) {
-            $params['filter[where][datetimeStart][lte]'] = $toDate->format('c');
+            $params['filter[where][datetimeEnd][lte]'] = $toDate->format('c');
         }
         $params['filter[order][0][datetimeStart]'] = 'ASC';
 
         $params['parent'] = '1';
 
-        return $this->getApiRecords($this::URI, $params);
+        return $this->getApiRecords($params);
     }
 
     /**
@@ -115,16 +115,16 @@ class EventRepository extends BaseApiRepository
         }
         if ($fromDate !== null) {
             // Inutile a priori: la view de l'API s'en occupe déjà en amont
-            $params['filter[where][datetimeEnd][gte]'] = $fromDate->format('c');
+            $params['filter[where][datetimeStart][gte]'] = $fromDate->format('c');
         }
         if ($toDate !== null) {
-            $params['filter[where][datetimeStart][lte]'] = $toDate->format('c');
+            $params['filter[where][datetimeEnd][lte]'] = $toDate->format('c');
         }
         $params['filter[order][0][datetimeStart]'] = 'ASC';
 
         $params['children'] = '1';
 
-        return $this->getApiRecords($this::URI, $params);
+        return $this->getApiRecords($params);
     }
 
     /**
@@ -139,14 +139,15 @@ class EventRepository extends BaseApiRepository
     public function searchBy(int $organizationId, $searchParams = []) {
         $params = [];
         $params['organizationId'] = $organizationId;
-        $params['filter[order][0][datetimeEnd]'] = 'ASC';
+        $params['filter[order][0][datetimeStart]'] = 'ASC';
         $params = array_merge($params, $searchParams);
 
-        return $this->getApiRecords($this::URI, $params);
+        return $this->getApiRecords($params);
     }
 
     /**
      * Returns an Event object from an Api record
+     *
      * @param array $record A record as returned by $this->getApiRecords
      * @return Event|null
      * @throws Exception
@@ -167,9 +168,9 @@ class EventRepository extends BaseApiRepository
         $dateStart = new \DateTime($record['datetimeStart']);
         $dateStart->setTimezone(new \DateTimeZone('Europe/Paris'));
         $event->setDatetimeStart($dateStart);
-        $dateEnd = new \DateTime($record['datetimeStart']);
+        $dateEnd = new \DateTime($record['datetimeEnd']);
         $dateEnd->setTimezone(new \DateTimeZone('Europe/Paris'));
-        $event->setDatetimeEnd(new \DateTime($record['datetimeEnd'], new DateTimeZone("Europe/Paris")));
+        $event->setDatetimeEnd($dateEnd);
         $event->setDates($record['dates']);
         $event->setPlacename($record['placeName']);
         $event->setPlaceDescription($record['placeDescription']);

+ 13 - 8
ot_core/Classes/Domain/Repository/MemberRepository.php

@@ -9,11 +9,16 @@ use Opentalent\OtCore\Exception\ApiRequestException;
 
 class MemberRepository extends BaseApiRepository
 {
-    CONST URI = BaseApiRepository::BASE_URI . 'public/members';
-    CONST URI_CA = BaseApiRepository::BASE_URI . 'public/members_ca';
+    const URI_TRAILING_PART = 'public/members';
+    const URI_TRAILING_PART_CA = 'public/members_ca';
     const HYDRA_TYPE = 'PortailMemberBySpeciality';
     const HYDRA_TYPE_CA = 'PortailMemberByRole';
 
+    protected function getApiUriCa(): string
+    {
+        return $this->getApiUri(self::URI_TRAILING_PART_CA);
+    }
+
     /**
      * Get the members of the organization
      * If $only_ca is true, returns only the members onf the CA
@@ -30,9 +35,9 @@ class MemberRepository extends BaseApiRepository
         $params['itemsPerPage'] = '200';
 
         if ($only_ca) {
-            return $this->getApiRecords($this::URI_CA, $params);
+            return $this->getApiRecords($params, $this->getApiUriCa());
         } else {
-            return $this->getApiRecords($this::URI, $params);
+            return $this->getApiRecords($params);
         }
     }
 
@@ -66,10 +71,10 @@ class MemberRepository extends BaseApiRepository
         $member->setMission($record['mission']);
         $member->setPersonId((int)$record['personId']);
         $member->setImage($record['image']);
-        $member->setAdressCity($record['addressCity']);
-        $member->setStreetAdress($record['streetAddress']);
-        $member->setStreetAdressSecond($record['streetAddressSecond']);
-        $member->setStreetAdressThird($record['streetAddressThird']);
+        $member->setAddressCity($record['addressCity']);
+        $member->setStreetAddress($record['streetAddress']);
+        $member->setStreetAddressSecond($record['streetAddressSecond']);
+        $member->setStreetAddressThird($record['streetAddressThird']);
         $member->setPostalCode($record['postalCode']);
         $member->setTelphone($record['telphone']);
         $member->setMobilPhone($record['mobilPhone']);

+ 27 - 12
ot_core/Classes/Domain/Repository/OrganizationRepository.php

@@ -8,21 +8,22 @@ use Opentalent\OtCore\Exception\ApiRequestException;
 
 class OrganizationRepository extends BaseApiRepository
 {
-    CONST URI = BaseApiRepository::BASE_URI . 'public/organizations';
+    const URI_TRAILING_PART = 'public/organizations';
     const HYDRA_TYPE = 'PortailOrganization';
 
     /**
      * Get the organization by Id
      *
      * @param int $id The id of the organization
-     * @return array
+     * @return object
      * @throws ApiRequestException
      */
-    public function findById($id) {
+    public function findById($id): object
+    {
         $params = [];
         $params['filter[where][id]'] = $id;
-        $organization = $this->getApiFirstRecord($this::URI, $params);
-        if($organization == null) {
+        $organization = $this->getApiFirstRecord($params);
+        if ($organization == null) {
             throw new ApiRequestException('Organization with id ' . $id . ' does not exist');
         }
         return $organization;
@@ -32,17 +33,17 @@ class OrganizationRepository extends BaseApiRepository
      * Get the organization by name
      *
      * @param string name          The name of the organization
-     * @return array
+     * @return object
      * @throws \Exception
      * @throws ApiRequestException
      */
-    public function findByName($name)
+    public function findByName($name): object
     {
         $params = [];
         $params['filter[where][name]'] = $name;
-        $organization = $this->getApiFirstRecord($this::URI, $params);
+        $organization = $this->getApiFirstRecord($params);
         if($organization == null) {
-            throw new \Exception('Organization with name "' . $name . '" does not exist');
+            throw new ApiRequestException('Organization with name "' . $name . '" does not exist');
         }
         return $organization;
     }
@@ -56,7 +57,7 @@ class OrganizationRepository extends BaseApiRepository
      * @return ApiPagedCollection
      * @throws ApiRequestException
      */
-    public function findChildrenById(int $id, $searchParams = [], $page = 1)
+    public function findChildrenById(int $id, $searchParams = [], $page = 1): ApiPagedCollection
     {
         $params = [];
         $params['parentId'] = $id;
@@ -65,7 +66,21 @@ class OrganizationRepository extends BaseApiRepository
 
         $params = array_merge($params, $searchParams);
 
-        return $this->getApiRecords($this::URI, $params);
+        return $this->getApiRecords($params);
+    }
+
+    /**
+     * Get all organizations
+     *
+     * @return ApiPagedCollection
+     * @throws ApiRequestException
+     */
+    public function getAll(): ApiPagedCollection
+    {
+        $params = [];
+        $params['itemsPerPage'] = 999999;
+
+        return $this->getApiRecords($params);
     }
 
     /**
@@ -89,7 +104,7 @@ class OrganizationRepository extends BaseApiRepository
         $organization->setCategories($record['categories']);
         $organization->setAddressCity($record['addressCity']);
         $organization->setPostalCode($record['postalCode']);
-        $organization->setStreetAdress($record['streetAddress']);
+        $organization->setStreetAddress($record['streetAddress']);
         $organization->setLatitude($record['latitude']);
         $organization->setLongitude($record['longitude']);
         $organization->setCountry($record['country']);

+ 15 - 0
ot_core/Classes/Exception/InvalidWebsiteConfigurationException.php

@@ -0,0 +1,15 @@
+<?php
+
+
+namespace Opentalent\OtCore\Exception;
+
+
+use Exception;
+
+/**
+ * Class NoSuchWebsite
+ * Raise this exception when website has no domain defined in db
+ *
+ * @package Opentalent\OtCore\Exception
+ */
+class InvalidWebsiteConfigurationException extends Exception {}

+ 1 - 4
ot_core/Classes/Exception/NoSiteSelected.php

@@ -12,7 +12,4 @@ use Exception;
  *
  * @package Opentalent\OtCore\Exception
  */
-class NoSiteSelected extends Exception
-{
-
-}
+class NoSiteSelected extends Exception {}

+ 15 - 0
ot_core/Classes/Exception/NoSuchOrganizationException.php

@@ -0,0 +1,15 @@
+<?php
+
+
+namespace Opentalent\OtCore\Exception;
+
+
+use Exception;
+
+/**
+ * Class NoSuchOrganizationException
+ * Raise this exception when a non-existing organization is fetched via the Opentalent API
+ *
+ * @package Opentalent\OtCore\Exception
+ */
+class NoSuchOrganizationException extends Exception {}

+ 15 - 0
ot_core/Classes/Exception/NoSuchRecordException.php

@@ -0,0 +1,15 @@
+<?php
+
+
+namespace Opentalent\OtCore\Exception;
+
+
+use Exception;
+
+/**
+ * Class NoSuchRecord
+ * Raise this exception when a non-existing record is fetched
+ *
+ * @package Opentalent\OtCore\Exception
+ */
+class NoSuchRecordException extends Exception {}

+ 15 - 0
ot_core/Classes/Exception/NoSuchWebsiteException.php

@@ -0,0 +1,15 @@
+<?php
+
+
+namespace Opentalent\OtCore\Exception;
+
+
+use Exception;
+
+/**
+ * Class NoSuchWebsite
+ * Raise this exception when a non-existing website is fetched
+ *
+ * @package Opentalent\OtCore\Exception
+ */
+class NoSuchWebsiteException extends Exception {}

+ 63 - 0
ot_core/Classes/Middleware/Frontend/OtSiteResolver.php

@@ -0,0 +1,63 @@
+<?php
+declare(strict_types = 1);
+namespace Opentalent\OtCore\Middleware\Frontend;
+
+use Opentalent\OtCore\Exception\NoSuchWebsiteException;
+use Opentalent\OtCore\Website\OtWebsiteRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+use TYPO3\CMS\Core\Routing\SiteRouteResult;
+use TYPO3\CMS\Frontend\Controller\ErrorController;
+use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
+
+/**
+ *
+ */
+class OtSiteResolver extends \TYPO3\CMS\Frontend\Middleware\SiteResolver
+{
+    /**
+     * Resolve the site/language information by checking the page ID or the URL.
+     *
+     * @param ServerRequestInterface $request
+     * @param RequestHandlerInterface $handler
+     * @return ResponseInterface
+     */
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $otWebsiteRepository = GeneralUtility::makeInstance(ObjectManager::class)->get(OtWebsiteRepository::class);
+
+        try {
+            $devMode = $_SERVER['TYPO3_CONTEXT'] == "Development";
+
+            $otWebsite = $otWebsiteRepository->matchUriToWebsite($request->getUri(), $devMode);
+            $site = $otWebsiteRepository->generateWebsiteConfiguration($otWebsite);
+            $language = $site->getDefaultLanguage();
+            if ($devMode) {
+                preg_match("/\w+\/(.*)/", $request->getUri()->getPath(), $m);
+                $tail = $m[1] ?? "";
+            } else {
+                $tail = rtrim($request->getUri()->getPath(), '/');
+            }
+        } catch (NoSuchWebsiteException $e) {
+            // site not found
+            // either it will be redirected, or it will return a pageNotFound error during the page resolution
+            return $handler->handle($request);
+        }
+
+        $routeResult = new SiteRouteResult($request->getUri(), $site, $language, $tail);
+
+        $request = $request->withAttribute('ot_website', $otWebsite);
+        $request = $request->withAttribute('site', $routeResult->getSite());
+        $request = $request->withAttribute('language', $routeResult->getLanguage());
+        $request = $request->withAttribute('routing', $routeResult);
+
+        // At this point, we later get further route modifiers
+        // for bw-compat we update $GLOBALS[TYPO3_REQUEST] to be used later in TSFE.
+        $GLOBALS['TYPO3_REQUEST'] = $request;
+
+        return $handler->handle($request);
+    }
+}

+ 0 - 166
ot_core/Classes/Page/OtPageRepository.php

@@ -1,166 +0,0 @@
-<?php
-
-namespace Opentalent\OtCore\Page;
-
-use FluidTYPO3\Vhs\Service\PageService;
-use Opentalent\OtCore\Exception\NoSiteSelected;
-use TYPO3\CMS\Core\Database\ConnectionPool;
-use TYPO3\CMS\Core\Exception\SiteNotFoundException;
-use TYPO3\CMS\Core\Site\Entity\Site;
-use TYPO3\CMS\Core\Site\SiteFinder;
-use TYPO3\CMS\Core\Utility\GeneralUtility;
-use TYPO3\CMS\Extbase\Object\ObjectManager;
-use TYPO3\CMS\Frontend\Page\PageRepository;
-
-/**
- * Class OtPageRepository
- *
- * Provides some useful methods to query typo3 pages
- *
- * @package Opentalent\OtCore\Page
- */
-class OtPageRepository extends PageRepository
-{
-    /**
-     * Returns the root page of the given page website,
-     * or the page itself if the given page is
-     * already the rootpage of the site
-     *
-     * @param $pageUid
-     *
-     * @return array
-     */
-    public function getRootPageFor($pageUid) {
-        $pageService = GeneralUtility::makeInstance(ObjectManager::class)->get(PageService::class);
-        $rootLine = $pageService->getRootLine($pageUid);
-
-        for (end($rootLine); key($rootLine)!==null; prev($rootLine)){
-            $page = current($rootLine);
-            if ($page['is_siteroot'] == 1) {
-                $page = $this->getPage($page['uid']);
-                return $page;
-            }
-        }
-        return [];
-    }
-
-    /**
-     * Recursively returns all the subpages of the given page
-     *
-     * @param int $pageUid The uid of the parent page
-     * @param bool $withRestrictions Set to true to add the standard restrictions (deleted, forbidden...etc.)
-     * @return array
-     */
-    public function getAllSubpagesForPage(int $pageUid, bool $withRestrictions=false) {
-        $subpages = [];
-
-        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
-            ->getQueryBuilderForTable('pages');
-        if (!$withRestrictions) {
-            $queryBuilder->getRestrictions()->removeAll();
-        }
-        $stack = $queryBuilder
-            ->select('*')
-            ->from('pages')
-            ->where($queryBuilder->expr()->eq('pid', $pageUid))
-            ->execute()
-            ->fetchAll();
-
-        foreach ($stack as $page) {
-            $subpages[] = $page;
-            $children = $this->getAllSubpagesForPage($page['uid']);
-            if (!empty($children)) {
-                $subpages = array_merge($subpages, $children);
-            }
-        }
-        return $subpages;
-    }
-
-    /**
-     * Return the Site object for the given page
-     *
-     * @param int $pageUid
-     * @return Site
-     */
-    public function getSiteFor(int $pageUid) {
-        $rootPage = $this->getRootPageFor($pageUid);
-        $rootUid = $rootPage['uid'];
-
-        $siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
-        return $siteFinder->getSiteByRootPageId($rootUid);
-    }
-
-    /**
-     * Returns the typo3 site matching the current request (FE only)
-     *
-     * @return Site
-     */
-    public function getCurrentSite() {
-        $request = $GLOBALS['TYPO3_REQUEST'];
-        $site = $request->getAttribute('site');
-        return GeneralUtility::makeInstance(SiteFinder::class)
-            ->getSiteByIdentifier($site->getIdentifier());
-    }
-
-    /**
-     * Returns the current site's rootpage uid (FE only)
-     *
-     * @return int
-     */
-    public function getCurrentSiteRootPageId() {
-        $site = $this->getCurrentSite();
-        return $site->getRootPageId();
-    }
-
-    /**
-     * Returns the current site's rootpage URI (FE only)
-     *
-     * @return string
-     */
-    public function getCurrentSiteRootPageUri() {
-        $site = $this->getCurrentSite();
-        return $site->getBase();
-    }
-
-    /**
-     * [Frontend Only]
-     * Returns the current site's rootpage array
-     *
-     * @return array
-     */
-    public function getCurrentSiteRootPage() {
-        $uid = $this->getCurrentSiteRootPageUri();
-        return $this->getPage($uid);
-    }
-
-    /**
-     * [Backend Only]
-     * Returns the page currently selected in the backend if any
-     *
-     * @return int|null
-     */
-    public function getCurrentPageId() {
-        return (int)GeneralUtility::_GP('id');
-    }
-
-    /**
-     * [Backend Only]
-     * Return the root uid of the currently selected website if any,
-     * or throw a NoSiteSelected exception
-     *
-     * @return int
-     * @throws NoSiteSelected
-     */
-    public function getCurrentRootUid() {
-        $pageUid = $this->getCurrentPageId();
-
-        $otPageRepository = GeneralUtility::makeInstance(OtPageRepository::class);
-        $rootPage = $otPageRepository->getRootPageFor($pageUid);
-        $rootUid = $rootPage['uid'] ?? 0;
-        if (!$rootUid > 0) {
-            throw new NoSiteSelected();
-        }
-        return $rootUid;
-    }
-
-}

+ 33 - 0
ot_core/Classes/Utility/FileUtility.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Opentalent\OtCore\Utility;
+
+class FileUtility
+{
+    /**
+     * If $recursive is true, recursively delete a directory and all its content.
+     * Else, does a simple rmdir($dir)
+     *
+     * @param $dir
+     * @param bool $recursive
+     */
+    public static function rmdir($dir, $recursive=false) {
+        if (!$recursive) {
+            rmdir($dir);
+        } else {
+            if (is_dir($dir)) {
+                $objects = scandir($dir);
+                foreach ($objects as $object) {
+                    if ($object != "." && $object != "..") {
+                        if (is_dir($dir. DIRECTORY_SEPARATOR .$object) && !is_link($dir."/".$object))
+                            self::rmdir($dir. DIRECTORY_SEPARATOR. $object, true);
+                        else
+                            unlink($dir. DIRECTORY_SEPARATOR. $object);
+                    }
+                }
+                rmdir($dir);
+            }
+        }
+    }
+
+}

+ 14 - 0
ot_core/Classes/Utility/RouteNormalizer.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace Opentalent\OtCore\Utility;
+
+class RouteNormalizer
+{
+    public static function normalizePath(string $path) {
+        return '/' . trim($path, '/');
+    }
+
+    public static function normalizeDomain(string $domain) {
+        return preg_replace('/https?:\/\/([\w\.]+)(?:\/.*)?/', '$1', $domain);
+    }
+}

+ 12 - 2
ot_core/Classes/ViewHelpers/OtAbstractViewHelper.php

@@ -3,7 +3,7 @@
 namespace Opentalent\OtCore\ViewHelpers;
 
 use FluidTYPO3\Vhs\Utility\ErrorUtility;
-use Opentalent\OtCore\Page\OtPageRepository;
+use Opentalent\OtCore\Website\OtPageRepository;
 use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerAwareTrait;
 use TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper;
@@ -21,7 +21,7 @@ class OtAbstractViewHelper  extends AbstractViewHelper implements LoggerAwareInt
      * @var OtPageRepository
      *
      */
-    protected $pageRepository;
+    protected OtPageRepository $pageRepository;
 
     /**
      * Throw a VHS viewhelper error
@@ -37,4 +37,14 @@ class OtAbstractViewHelper  extends AbstractViewHelper implements LoggerAwareInt
     {
         $this->pageRepository = $pageRepository;
     }
+
+    /**
+     * Returns the language service.
+     *
+     * @return \TYPO3\CMS\Core\Localization\LanguageService
+     */
+    protected function getLanguageService(): \TYPO3\CMS\Core\Localization\LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
 }

+ 179 - 0
ot_core/Classes/Website/OtPageRepository.php

@@ -0,0 +1,179 @@
+<?php
+
+namespace Opentalent\OtCore\Website;
+
+use Opentalent\OtCore\Exception\NoSiteSelected;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Class OtPageRepository
+ *
+ * Provides some useful methods to query typo3 pages
+ *
+ * @package Opentalent\OtCore\Page
+ */
+class OtPageRepository
+{
+    /**
+     * @var \FluidTYPO3\Vhs\Service\PageService
+     */
+    private \FluidTYPO3\Vhs\Service\PageService $pageService;
+
+    public function injectPageService(\FluidTYPO3\Vhs\Service\PageService $pageService)
+    {
+        $this->pageService = $pageService;
+    }
+
+    /**
+     * @var \TYPO3\CMS\Frontend\Page\PageRepository
+     */
+    private \TYPO3\CMS\Frontend\Page\PageRepository $pageRepository;
+
+    public function injectPageRepository(\TYPO3\CMS\Frontend\Page\PageRepository $pageRepository)
+    {
+        $this->pageRepository = $pageRepository;
+    }
+
+    /**
+     * @var \TYPO3\CMS\Core\Database\ConnectionPool
+     */
+    private \TYPO3\CMS\Core\Database\ConnectionPool $connectionPool;
+
+    public function injectConnectionPool(\TYPO3\CMS\Core\Database\ConnectionPool $connectionPool)
+    {
+        $this->connectionPool = $connectionPool;
+    }
+
+    /**
+     * Returns all the subpages of the given page
+     *
+     * @param int $pid The uid of the parent page
+     * @param bool $withRestrictions Set to true to add the standard restrictions (deleted, forbidden...etc.)
+     * @return array
+     */
+    public function getPagesByPid(int $pid, bool $withRestrictions=false): array
+    {
+        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
+        if (!$withRestrictions) {
+            $queryBuilder->getRestrictions()->removeAll();
+        }
+        return $queryBuilder
+            ->select('*')
+            ->from('pages')
+            ->where($queryBuilder->expr()->eq('pid', $pid))
+            ->execute()
+            ->fetchAll();
+    }
+
+    /**
+     * Returns the root page of the given page website,
+     * or the page itself if the given page is
+     * already the rootpage of the site
+     *
+     * @param $pageUid
+     *
+     * @return array
+     */
+    public function getRootPageFor($pageUid): array
+    {
+        $rootLine = $this->pageService->getRootLine($pageUid);
+
+        for (end($rootLine); key($rootLine)!==null; prev($rootLine)){
+            $page = current($rootLine);
+            if ($page['is_siteroot'] == 1) {
+                $page = $this->pageRepository->getPage($page['uid'], true);
+                return $page;
+            }
+        }
+        return [];
+    }
+
+    /**
+     * Recursively returns all the subpages of the given page
+     *
+     * @param int $pageUid The uid of the parent page
+     * @param bool $withRestrictions Set to true to add the standard restrictions (deleted, forbidden...etc.)
+     * @return array
+     */
+    public function getAllSubpagesForPage(int $pageUid, bool $withRestrictions=false): array
+    {
+        $subpages = [];
+
+        $stack = $this->getPagesByPid($pageUid, $withRestrictions);
+
+        foreach ($stack as $page) {
+            $subpages[] = $page;
+            $children = $this->getAllSubpagesForPage($page['uid']);
+            if (!empty($children)) {
+                $subpages = array_merge($subpages, $children);
+            }
+        }
+        return $subpages;
+    }
+
+    /**
+     * Returns all the pages of the given page's website, starting from the root page
+     *
+     * @param int $pageUid
+     * @param bool $withRestrictions Set to true to add the standard restrictions (deleted, forbidden...etc.)
+     * @return array
+     */
+    public function getPageWithSubpages(int $pageUid, bool $withRestrictions=false): array
+    {
+        return array_merge([$this->getPage($pageUid)], $this->getAllSubpagesForPage($pageUid, $withRestrictions));
+    }
+
+    /**
+     * [Backend Only]
+     * Returns the page currently selected in the backend if any
+     *
+     * @return int|null
+     */
+    public function getCurrentBePageId(): ?int
+    {
+        return (int)GeneralUtility::_GP('id');
+    }
+
+    /**
+     * [Backend Only]
+     * Return the root uid of the currently selected website if any,
+     * or throw a NoSiteSelected exception
+     *
+     * @return int
+     * @throws NoSiteSelected
+     */
+    public function getCurrentBeRootUid(): int
+    {
+        $pageUid = $this->getCurrentBePageId();
+
+        $rootPage = $this->getRootPageFor($pageUid);
+        $rootUid = $rootPage['uid'] ?? 0;
+        if (!$rootUid > 0) {
+            throw new NoSiteSelected();
+        }
+        return $rootUid;
+    }
+
+    /**
+     * [Backend Only]
+     *
+     * Return the mountpoint(s) for the current BE User
+     */
+    public function getCurrentBeUserMountpoints(): array
+    {
+        // Check if the current be-user has a db_mountpoint, and only has one.
+        // If so, this will be considered as the selected site (since its the only one available)
+        $be_user = $GLOBALS['BE_USER'];
+        if ($be_user == null) {
+            return [];
+        }
+
+        $mountpoints = $be_user->returnWebmounts();
+        return array_filter($mountpoints, function($m) { return is_numeric($m) && (int)$m > 0; });
+    }
+
+    public function getPage(int $uid): array
+    {
+        return $this->pageRepository->getPage($uid, true);
+    }
+}

+ 472 - 0
ot_core/Classes/Website/OtWebsiteRepository.php

@@ -0,0 +1,472 @@
+<?php
+
+namespace Opentalent\OtCore\Website;
+
+use Opentalent\OtCore\Exception\InvalidWebsiteConfigurationException;
+use Opentalent\OtCore\Exception\NoSuchRecordException;
+use Opentalent\OtCore\Exception\NoSuchWebsiteException;
+use Opentalent\OtCore\Utility\RouteNormalizer;
+use Psr\Http\Message\UriInterface;
+use Symfony\Component\Yaml\Yaml;
+use TYPO3\CMS\Core\Http\Uri;
+use TYPO3\CMS\Core\Site\Entity\Site;
+
+/**
+ * Repository for the ot_websites table
+ *
+ * @package Opentalent\OtCore\Website
+ */
+class OtWebsiteRepository
+{
+    /**
+     * @var \TYPO3\CMS\Core\Database\ConnectionPool
+     */
+    private \TYPO3\CMS\Core\Database\ConnectionPool $connectionPool;
+
+    public function injectConnectionPool(\TYPO3\CMS\Core\Database\ConnectionPool $connectionPool)
+    {
+        $this->connectionPool = $connectionPool;
+    }
+
+    /**
+     * Get an OtWebsite by uid
+     *
+     * @throws NoSuchWebsiteException
+     */
+    public function getWebsiteByUid(int $uid, bool $withRestrictions = true): array
+    {
+        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('ot_websites');
+        if (!$withRestrictions) {
+            $queryBuilder->getRestrictions()->removeAll();
+        }
+        $website = $queryBuilder
+            ->select('*')
+            ->from('ot_websites')
+            ->where($queryBuilder->expr()->eq('uid', $uid))
+            ->execute()
+            ->fetch();
+        if (!isset($website['uid'])) {
+            throw new NoSuchWebsiteException('No website found with uid ' . $uid);
+        }
+        return $website;
+    }
+
+    /**
+     * Get the OtWebsite of the given organization
+     *
+     * @throws NoSuchWebsiteException
+     */
+    public function getWebsiteByOrganizationId(int $organizationId, bool $withRestrictions = true): array
+    {
+        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('ot_websites');
+        if (!$withRestrictions) {
+            $queryBuilder->getRestrictions()->removeAll();
+        }
+        $website = $queryBuilder
+            ->select('*')
+            ->from('ot_websites')
+            ->where($queryBuilder->expr()->eq('organization_id', $organizationId))
+            ->execute()
+            ->fetch();
+        if (!isset($website['uid'])) {
+            throw new NoSuchWebsiteException('No website found for organization ' . $organizationId);
+        }
+        return $website;
+    }
+
+    /**
+     * Get the OtWebsite of the given page
+     *
+     * @throws NoSuchWebsiteException
+     */
+    public function getWebsiteByPageUid(int $pageUid, bool $withRestrictions = true): array
+    {
+        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('ot_websites');
+        if (!$withRestrictions) {
+            $queryBuilder->getRestrictions()->removeAll();
+        }
+        $website = $queryBuilder
+            ->select('w.*')
+            ->from('ot_websites', 'w')
+            ->innerJoin('w', 'pages', 'p', $queryBuilder->expr()->eq('p.ot_website_uid', 'w.uid'))
+            ->where($queryBuilder->expr()->eq('p.uid', $pageUid))
+            ->execute()
+            ->fetch();
+        if (!isset($website['uid'])) {
+            throw new NoSuchWebsiteException('No website found for page ' . $pageUid);
+        }
+        return $website;
+    }
+
+    /**
+     * Get the OtWebsite by its config identifier
+     *
+     * @throws NoSuchWebsiteException
+     */
+    public function getWebsiteByConfigIdentifier(string $identifier): array
+    {
+        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('ot_websites');
+        $website = $queryBuilder
+            ->select('*')
+            ->from('ot_websites')
+            ->where($queryBuilder->expr()->eq('config_identifier', $queryBuilder->expr()->literal($identifier)))
+            ->execute()
+            ->fetch();
+        if (!isset($website['uid'])) {
+            throw new NoSuchWebsiteException('No website found for identifier ' . $identifier);
+        }
+        return $website;
+    }
+
+    /**
+     * Get the root page uid of the given OtWebsite
+     *
+     * @param int $websiteUid
+     * @param bool $withRestrictions
+     * @return int
+     * @throws NoSuchRecordException
+     */
+    public function getWebsiteRootUid(int $websiteUid, bool $withRestrictions = true): int {
+        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
+        if (!$withRestrictions) {
+            $queryBuilder->getRestrictions()->removeAll();
+        }
+        $rootUid = $queryBuilder
+            ->select('uid')
+            ->from('pages')
+            ->where($queryBuilder->expr()->eq('ot_website_uid', $websiteUid))
+            ->andWhere($queryBuilder->expr()->eq('is_siteroot', 1))
+            ->execute()
+            ->fetchColumn(0);
+        if (!$rootUid > 0) {
+            throw new NoSuchRecordException('No root page found for website ' . $websiteUid);
+        }
+        return $rootUid;
+    }
+
+    /**
+     * Try to find the root page uid of the organization's website and return it.
+     * Throw a Opentalent\OtAdmin\NoSuchWebsiteException exception if the website does not exist.
+     *
+     * @param int $organizationId
+     * @param bool $withRestrictions
+     * @return int
+     * @throws NoSuchWebsiteException
+     */
+    public function findRootUidForOrganization(int $organizationId, bool $withRestrictions = true): int
+    {
+        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
+        if (!$withRestrictions) {
+            $queryBuilder->getRestrictions()->removeAll();
+        }
+        $rootUid = $queryBuilder
+            ->select('p.uid')
+            ->from('pages', 'p')
+            ->innerJoin('p', 'ot_websites', 'w', $queryBuilder->expr()->eq('p.ot_website_uid', 'w.uid'))
+            ->where($queryBuilder->expr()->eq('w.organization_id', $organizationId))
+            ->andWhere($queryBuilder->expr()->eq('p.is_siteroot', 1))
+            ->execute()
+            ->fetchColumn(0);
+        if (!$rootUid) {
+            throw new NoSuchWebsiteException("No website found for organization " . $organizationId);
+        }
+        return $rootUid;
+    }
+
+    /**
+     * Retrieves the current full domain of the given website.
+     *
+     * @param array $website
+     * @return string
+     * @throws InvalidWebsiteConfigurationException
+     */
+    public function resolveWebsiteDomain(array $website): string
+    {
+        if ($website['custom_domain']) {
+            return $website['custom_domain'];
+        } else if ($website['subdomain']) {
+            return $website['subdomain'] . '.opentalent.fr';
+        }
+        throw new InvalidWebsiteConfigurationException("No domain defined for website " . $website['uid']);
+    }
+
+    /**
+     * Get the website current base uri
+     *
+     * @param array $website
+     * @return string
+     * @throws InvalidWebsiteConfigurationException
+     */
+    public function resolveWebsiteBaseUri(array $website): string
+    {
+        return 'https://' . $this->resolveWebsiteDomain($website);
+    }
+
+    /**
+     * Generate an array as it would be loaded from the site.yaml configuration
+     * file of the given website
+     *
+     * @param array $website
+     * @param string|null $identifier
+     * @return Site
+     * @throws InvalidWebsiteConfigurationException
+     * @throws NoSuchRecordException
+     */
+    public function generateWebsiteConfiguration(array $website, string $identifier = null): Site
+    {
+        $rootUid = $this->getWebsiteRootUid($website['uid']);
+
+        $identifier = $identifier ?? $website['config_identifier'];
+
+        return new Site(
+            $identifier,
+            $rootUid,
+            [
+                'base' => $this->resolveWebsiteBaseUri($website),
+                'baseVariants' => [0 => [
+                                'base' => $website['subdomain'] . '/',
+                                'condition' => 'applicationContext == "Development"',
+                                        ],
+                                ],
+                'errorHandling' => [0 => ['errorCode' => '404',
+                                            'errorHandler' => 'PHP',
+                                            'errorPhpClassFQCN' => 'Opentalent\\OtTemplating\\Page\\ErrorHandler',
+                                         ],
+                                    1 => ['errorCode' => '403',
+                                            'errorHandler' => 'PHP',
+                                            'errorPhpClassFQCN' => 'Opentalent\\OtTemplating\\Page\\ErrorHandler',
+                                         ],
+                                    ],
+                'flux_content_types' => '',
+                'flux_page_templates' => '',
+                'languages' => [0 => [
+                                        'title' => 'Fr',
+                                        'enabled' => true,
+                                        'base' => '/',
+                                        'typo3Language' => 'fr',
+                                        'locale' => 'fr_FR',
+                                        'iso-639-1' => 'fr',
+                                        'navigationTitle' => 'Fr',
+                                        'hreflang' => 'fr-FR',
+                                        'direction' => 'ltr',
+                                        'flag' => 'fr',
+                                        'languageId' => '0',
+                                     ],
+                              ],
+                'rootPageId' => $rootUid,
+                'routes' => [],
+            ]
+        );
+    }
+
+    /**
+     * Returns the typo3 site matching the current request (FE only!)
+     *
+     * @return array
+     * @throws NoSuchWebsiteException
+     */
+    public function getCurrentWebsiteFromFEGlobals(): array
+    {
+        $request = $GLOBALS['TYPO3_REQUEST'];
+        $ot_website = $GLOBALS['TYPO3_REQUEST']->getAttribute('ot_website');
+        if ($ot_website) {
+            return $ot_website;
+        } else {
+            $site = $request->getAttribute('site');
+            return $this->getWebsiteByConfigIdentifier($site->getIdentifier());
+        }
+    }
+
+    /**
+     * Returns the typo3 site matching the current request (FE only!)
+     *
+     * @return int
+     * @throws NoSuchWebsiteException
+     */
+    public function getCurrentRootpageUidFromFEGlobals(): int
+    {
+        $website = $this->getCurrentWebsiteFromFEGlobals();
+        return $this->getWebsiteRootUid($website['uid']);
+    }
+
+    /**
+     * Returns the current site's rootpage URI (FE only)
+     *
+     * @return string
+     * @throws InvalidWebsiteConfigurationException
+     * @throws NoSuchWebsiteException
+     */
+    public function getCurrentSiteRootUriFromFeGlobals(): string
+    {
+        $website = $this->getCurrentWebSiteFromFEGlobals();
+        return $this->resolveWebsiteBaseUri($website);
+    }
+
+    /**
+     * Try to retrieve the website matching the given Uri and return the given website
+     *
+     * @param \Psr\Http\Message\UriInterface $uri
+     * @param bool $devMode
+     * @return Site
+     * @throws NoSuchWebsiteException
+     */
+    public function matchUriToWebsite(\Psr\Http\Message\UriInterface $uri, bool $devMode=false): array
+    {
+        $queryBuilder = $this->connectionPool->getQueryBuilderForTable('ot_websites');
+
+        $q = $queryBuilder
+            ->select('*')
+            ->from('ot_websites');
+
+        $domain = RouteNormalizer::normalizeDomain($uri->getHost());
+        $path = RouteNormalizer::normalizePath($uri->getPath());
+
+        if ($devMode) {
+            preg_match("/([\w\-]+)(?:\/.*)?/", $path, $m);
+            $q = $q->where($queryBuilder->expr()->eq('subdomain', $queryBuilder->expr()->literal($m[1])));
+        } else {
+            preg_match("/([\w\-]+)\.opentalent\.fr/", $domain, $m);
+            if (count($m) > 0) {
+                $q = $q->where($queryBuilder->expr()->eq('subdomain', $queryBuilder->expr()->literal($m[1])));
+            } else {
+                $q = $q->where($queryBuilder->expr()->eq('custom_domain', $queryBuilder->expr()->literal($domain)));
+            }
+        }
+
+        $website = $q->execute()
+                    ->fetch();
+
+        if (!isset($website['uid'])) {
+            throw new NoSuchWebsiteException('No website found for this URI: ' . $uri);
+        }
+
+        return $website;
+    }
+
+    /**
+     * @param UriInterface $uri
+     * @param bool $devMode
+     * @param array|null $website
+     * @return int
+     * @throws NoSuchWebsiteException
+     */
+    public function matchUriToPage(array $otWebsite, UriInterface $uri, bool $devMode=false): int
+    {
+        $tail = $uri->getPath();
+        if ($devMode) {
+            $tail = preg_replace("/\/?[\w\-]+\/?(.*)/", "/$1", $tail);
+        }
+        if ($tail != "/") {
+            $tail = rtrim($tail, '/');
+        }
+
+        $q = $this->connectionPool->getQueryBuilderForTable('pages');
+        return $q
+            ->select('uid')
+            ->from('pages')
+            ->where($q->expr()->eq('ot_website_uid', $otWebsite['uid']))
+            ->andWhere($q->expr()->eq('slug', $q->expr()->literal($tail)))
+            ->execute()
+            ->fetchColumn(0);
+    }
+
+    /**
+     * Try to find the config file of the website in the less resource-consuming way
+     * and parse it.
+     *
+     * @param int $rootUid
+     * @param string|null $identifier
+     * @return array   Path of the configuration file and parsed configuration of the website
+     */
+    public function findConfigFileAndContentFor(int $rootUid, string $identifier = null): array
+    {
+        $configs_directory = $_ENV['TYPO3_PATH_APP'] . "/config/sites/";
+
+        if ($identifier !== null) {
+            $filename = $configs_directory . $identifier . "/config.yaml";
+            $yamlConfig = Yaml::parseFile($filename);
+
+            if ($yamlConfig['rootPageId'] === $rootUid) {
+                return [$filename, $yamlConfig];
+            } else {
+                throw new \RuntimeException("No configuration file found for identifier " . $identifier);
+            }
+        }
+
+        $candidates = array_filter(
+            scandir($configs_directory),
+            function ($x) { return $x != '.' && $x != '..'; }
+        );
+
+        // try to filter by directory name
+        foreach ($candidates as $subdir) {
+            if (preg_match('/\.*_' . $rootUid . '$/', $subdir)) {
+                $filename = $configs_directory . $subdir . '/config.yaml';
+                try {
+                    $yamlConfig = Yaml::parseFile($filename);
+
+                    if ($yamlConfig['rootPageId'] === $rootUid) {
+                        return [$filename, $yamlConfig];
+                    }
+                } catch (\Symfony\Component\Yaml\Exception\ParseException $e) {
+                    continue;
+                }
+            }
+        }
+
+        // it wasn't found the easy way, let's look to each file... :(
+        foreach ($candidates as $subdir) {
+            $filename = $configs_directory . $subdir . '/config.yaml';
+            try {
+                $yamlConfig = Yaml::parseFile($filename);
+                if ($yamlConfig['rootPageId'] === $rootUid) {
+                    return [$filename, $yamlConfig];
+                }
+            } catch (\Symfony\Component\Yaml\Exception\ParseException $e) {
+                continue;
+            }
+        }
+
+        return [null, []];
+    }
+
+    /**
+     * Similar to findConfigFileAndContentFor(), but only returns the parsed configuration
+     *
+     * @param int $rootUid
+     * @param string|null $identifier
+     * @return array   Configuration of the website
+     */
+    public function findConfigFor(int $rootUid, string $identifier = null): array
+    {
+        $pathAndConfig = $this->findConfigFileAndContentFor($rootUid, $identifier);
+        return $pathAndConfig[1];
+    }
+
+    /**
+     * Similar to findConfigFileAndContentFor(), but only returns the config file path
+     * @param int $rootUid
+     * @param string|null $identifier
+     * @return string   Path of the config file of the given website
+     */
+    public function findConfigFilePathFor(int $rootUid, string $identifier = null): string
+    {
+        $pathAndConfig = $this->findConfigFileAndContentFor($rootUid, $identifier);
+        return $pathAndConfig[0];
+    }
+
+    /**
+     * Find the site configuration identifier of the given website
+     * @param int $rootUid
+     * @return string|null   Config identifier
+     */
+    public function findConfigIdentifierFor(int $rootUid): ?string
+    {
+        $path = $this->findConfigFilePathFor($rootUid);
+        if ($path) {
+            return basename(dirname($path));
+        } else {
+            return null;
+        }
+    }
+}

+ 18 - 0
ot_core/Configuration/RequestMiddlewares.php

@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * Register middlewares, which will be triggered at each request
+ */
+return [
+    'frontend' => [
+        'typo3/cms-frontend/site' => [
+            'target' => Opentalent\OtCore\Middleware\Frontend\OtSiteResolver::class,
+            'before' => [
+                'typo3/cms-adminpanel/initiator'
+            ],
+            'after' => [
+                'typo3/cms-adminpanel/sql-logging'
+            ],
+        ],
+    ],
+];

+ 5 - 4
ot_core/Configuration/TCA/Overrides/pages.php

@@ -3,12 +3,13 @@ defined('TYPO3_MODE') or die();
 
 // ** Add fields to the backend
 $columns = array (
-    'tx_opentalent_structure_id' => array (
-        'label' => 'Id de la structure',
+    'ot_website_uid' => array (
+        'label' => 'LLL:EXT:ot_core/Resources/Private/Language/locallang.xlf:website_uid',
+        'displayCond' => 'HIDE_FOR_NON_ADMINS',
         'config' => array (
             'type' => 'input',
         )
-    ),
+    )
 );
 
 \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTCAcolumns(
@@ -18,7 +19,7 @@ $columns = array (
 
 \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addToAllTCAtypes(
     'pages',
-    'tx_opentalent_structure_id',
+    'ot_website_uid',
     '',
     'after:subtitle'
 );

+ 5 - 0
ot_core/Configuration/ot_config.yaml

@@ -0,0 +1,5 @@
+
+# Variant Uri for development purposes
+api_variant_uri:
+  preprod.opentalent.fr: https://api.preprod.opentalent.fr/api/
+  local.opentalent.fr: https://local.api.opentalent.fr/api/

+ 6 - 1
ot_core/Readme.md

@@ -15,4 +15,9 @@ Cette extension fournit de nombreuses classes et ressources communes aux autres
 
 * Cette extension ne doit dépendre d'aucune autre pour fonctionner, c'est à dire que son fonctionnement ne doit 
 pas être modifié si aucune autre extension n'est installée
-* Cette extension est aussi la seule dont les autres extensions doivent pouvoir dépendre
+
+### Pour lancer les tests unitaires:
+
+Lancer dans la console:
+
+    .Build/bin/phpunit --coverage-clover=unittest-coverage.clover -c Tests/Build/UnitTests.xml

+ 13 - 1
ot_core/Resources/Private/Language/locallang.xlf

@@ -1,12 +1,24 @@
 <?xml version="1.0" encoding="utf-8" standalone="yes" ?>
 <xliff version="1.0">
-	<file source-language="fr" datatype="plaintext" original="messages" date="2020-04-03T15:44:11Z" product-name="ot_templating">
+	<file source-language="fr" datatype="plaintext" original="messages" date="2020-04-03T15:44:11Z" product-name="ot_core">
 		<header/>
 		<body>
 			<!--  -->
+			<trans-unit id="an_error_occured">
+				<source>Une erreur s'est produite lors de l'opération, veuillez contacter un administrateur.</source>
+			</trans-unit>
 			<trans-unit id="noPageSelected">
 				<source>Veuillez sélectionner une des pages du site à personnaliser pour accéder à cette rubrique.</source>
 			</trans-unit>
+			<trans-unit id="structure_id">
+				<source>Id de la structure</source>
+			</trans-unit>
+			<trans-unit id="structure_subdomain">
+				<source>Sous-domaine de la structure</source>
+			</trans-unit>
+			<trans-unit id="website_uid">
+				<source>Website Uid</source>
+			</trans-unit>
 		</body>
 	</file>
 </xliff>

+ 40 - 0
ot_core/Tests/Build/UnitTests.xml

@@ -0,0 +1,40 @@
+<phpunit
+        backupGlobals="true"
+        backupStaticAttributes="false"
+        bootstrap="../../.Build/vendor/nimut/testing-framework/res/Configuration/UnitTestsBootstrap.php"
+        colors="true"
+        convertErrorsToExceptions="true"
+        convertWarningsToExceptions="false"
+        convertDeprecationsToExceptions="false"
+        forceCoversAnnotation="false"
+        processIsolation="false"
+        stopOnError="false"
+        stopOnFailure="false"
+        stopOnIncomplete="false"
+        stopOnSkipped="false"
+        verbose="false"
+>
+    <testsuites>
+        <testsuite name="Base tests">
+            <directory>../Unit</directory>
+        </testsuite>
+    </testsuites>
+
+    <filter>
+        <whitelist processUncoveredFilesFromWhitelist="true">
+            <directory suffix=".php">../../Classes</directory>
+            <exclude>
+            </exclude>
+        </whitelist>
+    </filter>
+
+    <logging>
+        <log type="coverage-html" target="../../coverage" lowUpperBound="35" highLowerBound="70"/>
+        <log type="junit" target="../../coverage/junit-report.xml"/>
+    </logging>
+
+    <php>
+        <!-- suppress deprecation warnings - set error reporting to: E_ALL & ~E_NOTICE & ~E_USER_ERROR & ~E_USER_WARNING & ~E_USER_NOTICE & ~E_STRICT & ~E_USER_DEPRECATED -->
+        <ini name="error_reporting" value="12535"/>
+    </php>
+</phpunit>

+ 99 - 0
ot_core/Tests/Unit/Controller/SelectedSiteControllerTest.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Controller;
+
+use Nimut\TestingFramework\TestCase\UnitTestCase;
+use Opentalent\OtCore\Controller\SelectedSiteController;
+use Opentalent\OtCore\Exception\NoSiteSelected;
+use Opentalent\OtCore\Exception\NoSuchWebsiteException;
+use Opentalent\OtCore\Website\OtPageRepository;
+use Opentalent\OtCore\Website\OtWebsiteRepository;
+use Prophecy\Argument;
+
+class SelectedSiteControllerTest extends UnitTestCase
+{
+    private $controller;
+
+    public function setUp() {
+        $this->controller = new SelectedSiteController();
+    }
+
+    private function injectPageRepositoryWithSelectedUidAndMountpoints(?int $selectedUid, array $mountpoints = []) {
+        $otPageRepository = $this->prophesize(OtPageRepository::class);
+
+        if ($selectedUid != null) {
+            $otPageRepository->getCurrentBeRootUid()->willReturn($selectedUid);
+        } else {
+            $otPageRepository->getCurrentBeRootUid()->willThrow(new NoSiteSelected());
+        }
+        $otPageRepository->getCurrentBeUserMountpoints()->shouldBeCalled()->willReturn($mountpoints);
+        $this->controller->injectOtPageRepository($otPageRepository->reveal());
+
+        $otWebsiteRepository = $this->prophesize(OtWebsiteRepository::class);
+        $otWebsiteRepository->getWebsiteByPageUid(Argument::type('integer'))->willReturn(['uid' => 1, 'title' => 'Foo']);
+        $this->controller->injectOtWebsiteRepository($otWebsiteRepository->reveal());
+
+    }
+
+    private function callActionMethodProxy() {
+        $reflection = new \ReflectionObject($this->controller);
+        $method = $reflection->getMethod('callActionMethod');
+        $method->setAccessible(true);
+
+        $argumentsProperty = $reflection->getProperty('preventPropagation');
+        $argumentsProperty->setAccessible(true);;
+        $argumentsProperty->setValue($this->controller, true);
+
+        $method->invokeArgs($this->controller, []);
+
+        $currentRootUidProperty = $reflection->getProperty('currentRootUid');
+        $currentRootUidProperty->setAccessible(true);
+
+        return $currentRootUidProperty->getValue($this->controller);
+    }
+
+    /**
+     * If the current Be-user has only one website mounted,
+     * then the currentRootUid should be this website root uid
+     *
+     * @test
+     */
+    public function callActionMethodForSingleSite() {
+
+        $this->injectPageRepositoryWithSelectedUidAndMountpoints(null, [1]);
+
+        $currentRootUid = $this->callActionMethodProxy();
+
+        $this->assertEquals(1, $currentRootUid);
+    }
+
+    /**
+     * If the current Be-user has many websites mounted and a website page is selected,
+     * then the currentRootUid should be the currently selected website root page uid
+     *
+     * @test
+     */
+    public function callActionMethodForMultiSite() {
+
+        $this->injectPageRepositoryWithSelectedUidAndMountpoints(2, [1, 2]);
+
+        $currentRootUid = $this->callActionMethodProxy();
+
+        $this->assertEquals(2, $currentRootUid);
+    }
+
+    /**
+     * If the current Be-user has many websites mounted and no website page is selected,
+     * then the currentRootUid should be null
+     *
+     * @test
+     */
+    public function callActionMethodForNoSite() {
+
+        $this->injectPageRepositoryWithSelectedUidAndMountpoints(null, [1, 2]);
+
+        $currentRootUid = $this->callActionMethodProxy();
+
+        $this->assertNull($currentRootUid);
+    }
+}

+ 75 - 0
ot_core/Tests/Unit/Domain/Model/DonorTest.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain;
+
+use AssertionError;
+use Nimut\TestingFramework\TestCase\UnitTestCase;
+use Opentalent\OtCore\Domain\Model\Donor;
+
+class DonorTest extends UnitTestCase
+{
+    /**
+     * Object should instantiate correctly, and properties
+     * set with setters should be retrieved by getters
+     *
+     * @test
+     */
+    public function instantiation() {
+        $donor = new Donor();
+
+        $donor->setId(1);
+        $donor->setOrganizationId(2);
+        $donor->setName('name');
+        $donor->setEmail('mail@domain.com');
+        $donor->setTelphone('000000');
+        $donor->setWebsite('www.site.org');
+        $donor->setWording('abcd');
+        $donor->setDisplayedOn('efgh');
+        $donor->setLogo('logo.svg');
+
+        $this->assertEquals(1, $donor->getId());
+        $this->assertEquals(2, $donor->getOrganizationId());
+        $this->assertEquals('name', $donor->getName());
+        $this->assertEquals('mail@domain.com', $donor->getEmail());
+        $this->assertEquals('000000', $donor->getTelphone());
+        $this->assertEquals('www.site.org', $donor->getWebsite());
+        $this->assertEquals('abcd', $donor->getWording());
+        $this->assertEquals('efgh', $donor->getDisplayedOn());
+        $this->assertEquals('logo.svg', $donor->getLogo());
+    }
+
+    /**
+     * Nullable fields should be able to be set to null,
+     * while non nullable shall raise an exception if set to null
+     *
+     * @test
+     */
+    public function nullableOrNot() {
+
+        $donor = new Donor();
+
+        // non-nullable properties
+        try {
+            $donor->setId(null);
+            throw new AssertionError('Donor::setId should not accept a null value');
+        } catch (\TypeError $e) {}
+        try {
+            $donor->setOrganizationId(null);
+            throw new AssertionError('Donor::setOrganizationId should not accept a null value');
+        } catch (\TypeError $e) {}
+
+        // nullable properties
+        $donor->setName(null);
+        $donor->setEmail(null);
+        $donor->setTelphone(null);
+        $donor->setWebsite(null);
+        $donor->setWording(null);
+        $donor->setDisplayedOn(null);
+        $donor->setLogo(null);
+
+        // Just to avoid this test to be considered as risky
+        $this->assertEquals(1, 1);
+    }
+
+
+}

+ 430 - 0
ot_core/Tests/Unit/Domain/Model/EventTest.php

@@ -0,0 +1,430 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain;
+
+use AssertionError;
+use Nimut\TestingFramework\TestCase\UnitTestCase;
+use Opentalent\OtCore\Domain\Model\Event;
+use Opentalent\OtCore\Domain\Model\Organization;
+
+class EventTest extends UnitTestCase
+{
+    /**
+     * Object should instantiate correctly, and properties
+     * set with setters should be retrieved by getters
+     *
+     * @test
+     */
+    public function canInstantiate() {
+        $event = new Event();
+
+        $event->setId(1);
+        $event->setType('PortailEvent');
+        $event->setOrganizationId(2);
+        $event->setSubdomain('subdomain');
+        $event->setName('name');
+        $event->setDescription('description');
+        $event->setCategories(['categorie']);
+        $event->setUrl('www.url.com');
+        $event->setRule('rule');
+        $event->setDatetimeStart(new \DateTime('2021-01-01'));
+        $event->setDatetimeEnd(new \DateTime('2121-01-01'));
+        $event->setDates('dates');
+        $event->setPlacename('placename');
+        $event->setPlaceDescription('place description');
+        $event->setPlaceFloorSize('floor size');
+        $event->setPlaceCapacity('place capacity');
+        $event->setCity('casablanca');
+        $event->setPostalCode('00000');
+        $event->setStreetAddress('adress');
+        $event->setLongitude(1.23456);
+        $event->setLatitude(1.23456);
+        $event->setRoomName('room');
+        $event->setRoomDescription('room description');
+        $event->setRoomLocalisation('room localization');
+        $event->setRoomCapacity('room capacity');
+        $event->setRoomFloorSize('room floorsize');
+        $event->setZupId(3);
+        $event->setDeepLink('deeplink');
+        $event->setImage('logo.svg');
+        $event->setPriceMini(10.50);
+        $event->setMeetingSchedule(['meeting']);
+        $event->setApi(true);
+        $event->setParentName('parent');
+        $event->setParentSubdomain('parent.org');
+
+        $organization = new Organization();
+        $event->setOrganization($organization);
+
+        $this->assertEquals(1, $event->getId());
+        $this->assertEquals('PortailEvent', $event->getType());
+        $this->assertEquals(2, $event->getOrganizationId());
+        $this->assertEquals('subdomain', $event->getSubdomain());
+        $this->assertEquals('name', $event->getName());
+        $this->assertEquals('description', $event->getDescription());
+        $this->assertEquals(['categorie'], $event->getCategories());
+        $this->assertEquals('www.url.com', $event->getUrl());
+        $this->assertEquals('rule', $event->getRule());
+        $this->assertEquals(new \DateTime('2021-01-01'), $event->getDatetimeStart());
+        $this->assertEquals(new \DateTime('2121-01-01'), $event->getDatetimeEnd());
+        $this->assertEquals('dates', $event->getDates());
+        $this->assertEquals('placename', $event->getPlacename());
+        $this->assertEquals('place description', $event->getPlaceDescription());
+        $this->assertEquals('floor size', $event->getPlaceFloorSize());
+        $this->assertEquals('place capacity', $event->getPlaceCapacity());
+        $this->assertEquals('casablanca', $event->getCity());
+        $this->assertEquals('00000', $event->getPostalCode());
+        $this->assertEquals('adress', $event->getStreetAddress());
+        $this->assertEquals(1.23456, $event->getLongitude());
+        $this->assertEquals(1.23456, $event->getLatitude());
+        $this->assertEquals('room', $event->getRoomName());
+        $this->assertEquals('room description', $event->getRoomDescription());
+        $this->assertEquals('room localization', $event->getRoomLocalisation());
+        $this->assertEquals('room capacity', $event->getRoomCapacity());
+        $this->assertEquals('room floorsize', $event->getRoomFloorSize());
+        $this->assertEquals(3, $event->getZupId());
+        $this->assertEquals('deeplink', $event->getDeepLink());
+        $this->assertEquals('logo.svg', $event->getImage());
+        $this->assertEquals(10.50, $event->getPriceMini());
+        $this->assertEquals(['meeting'], $event->getMeetingSchedule());
+        $this->assertEquals(true, $event->getApi());
+        $this->assertEquals(true, $event->isApi());
+        $this->assertEquals('parent', $event->getParentName());
+        $this->assertEquals('parent.org', $event->getParentSubdomain());
+        $this->assertEquals($organization, $event->getOrganization());
+    }
+
+    /**
+     * Nullable fields should be able to be set to null,
+     * while non nullable shall raise an exception if set to null
+     *
+     * @test
+     */
+    public function nullableOrNot() {
+
+        $event = new Event();
+
+        // non-nullable properties
+        try {
+            $event->setId(null);
+            throw new AssertionError('Event::setId should not accept a null value');
+        } catch (\TypeError $e) {}
+        try {
+            $event->setOrganizationId(null);
+            throw new AssertionError('Event::setOrganizationId should not accept a null value');
+        } catch (\TypeError $e) {}
+
+        // nullable properties
+        $event->setType(null);
+        $event->setSubdomain(null);
+        $event->setName(null);
+        $event->setDescription(null);
+        $event->setCategories(null);
+        $event->setUrl(null);
+        $event->setRule(null);
+        $event->setDatetimeStart(null);
+        $event->setDatetimeEnd(null);
+        $event->setDates(null);
+        $event->setPlacename(null);
+        $event->setPlaceDescription(null);
+        $event->setPlaceFloorSize(null);
+        $event->setPlaceCapacity(null);
+        $event->setCity(null);
+        $event->setPostalCode(null);
+        $event->setStreetAddress(null);
+        $event->setLongitude(null);
+        $event->setLatitude(null);
+        $event->setRoomName(null);
+        $event->setRoomDescription(null);
+        $event->setRoomLocalisation(null);
+        $event->setRoomCapacity(null);
+        $event->setRoomFloorSize(null);
+        $event->setZupId(null);
+        $event->setDeepLink(null);
+        $event->setImage(null);
+        $event->setPriceMini(null);
+        $event->setMeetingSchedule(null);
+        $event->setApi(null);
+        $event->setParentName(null);
+        $event->setParentSubdomain(null);
+
+        // Just to avoid this test to be considered as risky
+        $this->assertEquals(1, 1);
+    }
+
+    /**
+     * getFormattedDates() should format a correct sentence when
+     * the start date and the end date are on two different days
+     *
+     * @test
+     */
+    public function getFormattedDatesOnDifferentDays()
+    {
+        $event = new Event();
+        $event->setDatetimeStart(new \DateTime('2021-01-01 09:00'));
+        $event->setDatetimeEnd(new \DateTime('2021-01-31 18:00'));
+        $this->assertEquals("Du 01/01/2021 09h00 au 31/01/2021 18h00", $event->getFormattedDates());
+    }
+
+    /**
+     * getFormattedDates should format a correct sentence when
+     * the start date and the end date are on the same day
+     *
+     * @test
+     */
+    public function getFormattedDatesOnSameDay()
+    {
+        $event = new Event();
+        $event->setDatetimeStart(new \DateTime('2021-01-01 09:00'));
+        $event->setDatetimeEnd(new \DateTime('2021-01-01 18:00'));
+        $this->assertEquals("Le 01/01/2021 de 09h00 à 18h00", $event->getFormattedDates());
+    }
+
+    /**
+     * getFormattedDates should format a correct sentence when
+     * no end date has been provided
+     *
+     * @test
+     */
+    public function getFormattedDatesWithStartDateOnly()
+    {
+        $event = new Event();
+        $event->setDatetimeStart(new \DateTime('2021-01-01 09:00'));
+        $this->assertEquals("A partir du 01/01/2021 09h00", $event->getFormattedDates());
+    }
+
+    /**
+     * getFormattedDates should format a correct sentence when
+     * no start date has been provided
+     *
+     * @test
+     */
+    public function getFormattedDatesWithEndDateOnly()
+    {
+        $event = new Event();
+        $event->setDatetimeEnd(new \DateTime('2021-01-31 18:00'));
+        $this->assertEquals("Jusqu'au 31/01/2021 18h00", $event->getFormattedDates());
+    }
+
+    /**
+     * getFormattedDates should return an empty sentence when
+     * no date has been provided
+     *
+     * @test
+     */
+    public function getFormattedDatesWithNoDates()
+    {
+        $event = new Event();
+        $this->assertEquals("", $event->getFormattedDates());
+    }
+
+    /**
+     * getLocAndDate should return the placename, city and the formatted dates
+     * of the events, regarding which informations are available
+     *
+     * @test
+     */
+    public function getLocAndDateWithFullInformations() {
+        $event = new Event();
+        $event->setPlacename("Place");
+        $event->setCity("City");
+        $event->setDatetimeStart(new \DateTime('2021-01-01 09:00'));
+        $event->setDatetimeEnd(new \DateTime('2021-01-01 18:00'));
+        $this->assertEquals("Place (City), le 01/01/2021 de 09h00 à 18h00", $event->getLocAndDate());
+    }
+
+    /**
+     * getLocAndDate should return the placename, city and the formatted dates
+     * of the events, regarding which informations are available
+     *
+     * @test
+     */
+    public function getLocAndDateWithPlaceAndCity() {
+        $event = new Event();
+        $event->setPlacename("Place");
+        $event->setCity("City");
+        $this->assertEquals("Place (City)", $event->getLocAndDate());
+    }
+
+    /**
+     * getLocAndDate should return the placename, city and the formatted dates
+     * of the events, regarding which informations are available
+     *
+     * @test
+     */
+    public function getLocAndDateWithOnlyPlace() {
+        $event = new Event();
+        $event->setPlacename("Place");
+        $this->assertEquals("Place", $event->getLocAndDate());
+    }
+
+    /**
+     * getLocAndDate should return the placename, city and the formatted dates
+     * of the events, regarding which informations are available
+     *
+     * @test
+     */
+    public function getLocAndDateWithOnlyCity() {
+        $event = new Event();
+        $event->setCity("City");
+        $this->assertEquals("City", $event->getLocAndDate());
+    }
+
+    /**
+     * getFullAdress should return a concateneted address of the event,
+     * regarding which informations are available
+     *
+     * @test
+     */
+    public function getFullAddress() {
+        $event = new Event();
+        $event->setPlacename("Place");
+        $event->setRoomName("room");
+        $event->setRoomLocalisation("loc");
+        $event->setStreetAddress("street");
+        $event->setCity("City");
+        $event->setPostalCode("00000");
+
+        $this->assertEquals(
+            "Place\nroom\nloc\nstreet\n00000 City",
+            $event->getFullAddress()
+        );
+    }
+
+    /**
+     * getInfosTable should return an array holding the informations of the event,
+     * regarding which informations are available
+     *
+     * @test
+     */
+    public function getInfosTable() {
+        $event = new Event();
+        $event->setPlacename("Place");
+        $event->setPlaceDescription("description");
+        $event->setCity("City");
+        $event->setUrl("event.com");
+
+        $this->assertEquals(
+            [
+                'Lieu' => 'Place',
+                'Description du lieu' => 'description',
+                'Adresse' => "Place\nCity",
+                'Lien externe' => 'event.com',
+            ],
+            $event->getInfosTable()
+        );
+    }
+
+    /**
+     * getShortDescription should return an eluded description
+     * if the description is longer than 100 cars
+     *
+     * @test
+     */
+    public function getShortDescriptionWithLongDescription()
+    {
+
+        $long_description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt " .
+        "ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut  " .
+        "aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore " .
+        "eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt " .
+        "mollit anim id est laborum.";
+
+        $event = new Event();
+        $event->setDescription($long_description);
+
+        $this->assertEquals(
+            "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore...",
+            $event->getShortDescription()
+        );
+    }
+
+    /**
+     * getShortDescription should return the actual description
+     * if the description is shorter than 100 cars
+     *
+     * @test
+     */
+    public function getShortDescriptionWithShortDescription() {
+
+        $long_description = "Lorem ipsum dolor sit amet";
+
+        $event = new Event();
+        $event->setDescription($long_description);
+
+        $this->assertEquals(
+            "Lorem ipsum dolor sit amet",
+            $event->getShortDescription()
+        );
+    }
+
+    /**
+     * getShortDescription should return an empty string
+     * if the description is null or empty
+     *
+     * @test
+     */
+    public function getShortDescriptionWithNoDescription() {
+        $event = new Event();
+
+        $this->assertEquals(
+            "",
+            $event->getShortDescription()
+        );
+    }
+
+    /**
+     * getInfosLink should return the url property of the event
+     * if this url is valid
+     *
+     * @test
+     */
+    public function getInfosLinkWithValidUrl() {
+        $event = new Event();
+        $url = 'https://structure.org/event?id=1';
+
+        $event->setUrl($url);
+
+        $this->assertEquals(
+            $url,
+            $event->getInfosLink()
+        );
+    }
+
+    /**
+     * getInfosLink should return the url of the website's organization
+     * if this url is invalid
+     *
+     * @test
+     */
+    public function getInfosLinkWithInvalidUrl() {
+        $event = new Event();
+        $url = 'ma structure';
+
+        $event->setUrl($url);
+        $event->setSubdomain('sub');
+
+        $this->assertEquals(
+            'https://sub.opentalent.fr',
+            $event->getInfosLink()
+        );
+    }
+
+    /**
+     * getInfosLink should return the url of the website's organization
+     * if this url is invalid
+     *
+     * @test
+     */
+    public function getInfosLinkWithNoData() {
+        $event = new Event();
+        $url = 'ma structure';
+        $event->setUrl($url);
+
+        $this->assertEquals(
+            '',
+            $event->getInfosLink()
+        );
+    }
+
+}

+ 155 - 0
ot_core/Tests/Unit/Domain/Model/MemberTest.php

@@ -0,0 +1,155 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain;
+
+use AssertionError;
+use Nimut\TestingFramework\TestCase\UnitTestCase;
+use Opentalent\OtCore\Domain\Model\Member;
+
+class MemberTest extends UnitTestCase
+{
+    /**
+     * Object should instantiate correctly, and properties
+     * set with setters should be retrieved by getters
+     *
+     * @test
+     */
+    public function instantiation() {
+        $member = new Member();
+
+        $member->setId(1);
+        $member->setOrganizationId(2);
+        $member->setGender('M');
+        $member->setGivenName('given name');
+        $member->setName('name');
+        $member->setStartDate(new \DateTime('2021-01-01'));
+        $member->setEndDate(new \DateTime('2021-01-01'));
+        $member->setInstrumentGroup('instrument group');
+        $member->setInstrument('instrument');
+        $member->setMission('mission');
+        $member->setPersonId(3);
+        $member->setImage('image');
+        $member->setAddressCity('city');
+        $member->setStreetAddress('street');
+        $member->setStreetAddressSecond('address2');
+        $member->setStreetAddressThird('address3');
+        $member->setPostalCode('00000');
+        $member->setTelphone('0101010101');
+        $member->setMobilPhone('0601010101');
+        $member->setEmail('mail@domain.com');
+
+        $this->assertEquals(1, $member->getId());
+        $this->assertEquals(2, $member->getOrganizationId());
+        $this->assertEquals('M', $member->getGender());
+        $this->assertEquals('given name', $member->getGivenName());
+        $this->assertEquals('name', $member->getName());
+        $this->assertEquals(new \DateTime('2021-01-01'), $member->getStartDate());
+        $this->assertEquals(new \DateTime('2021-01-01'), $member->getEndDate());
+        $this->assertEquals('instrument group', $member->getInstrumentGroup());
+        $this->assertEquals('instrument', $member->getInstrument());
+        $this->assertEquals('mission', $member->getMission());
+        $this->assertEquals(3, $member->getPersonId());
+        $this->assertEquals('image', $member->getImage());
+        $this->assertEquals('city', $member->getAddressCity());
+        $this->assertEquals('street', $member->getStreetAddress());
+        $this->assertEquals('address2', $member->getStreetAddressSecond());
+        $this->assertEquals('address3', $member->getStreetAddressThird());
+        $this->assertEquals('address3', $member->getStreetAddressThird());
+        $this->assertEquals('00000', $member->getPostalCode());
+        $this->assertEquals('0101010101', $member->getTelphone());
+        $this->assertEquals('0601010101', $member->getMobilPhone());
+        $this->assertEquals('mail@domain.com', $member->getEmail());
+    }
+
+    /**
+     * Nullable fields should be able to be set to null,
+     * while non nullable shall raise an exception if set to null
+     *
+     * @test
+     */
+    public function nullableOrNot() {
+
+        $member = new Member();
+
+        // non-nullable properties
+        try {
+            $member->setId(null);
+            throw new AssertionError('Member::setId should not accept a null value');
+        } catch (\TypeError $e) {}
+        try {
+            $member->setOrganizationId(null);
+            throw new AssertionError('Member::setOrganizationId should not accept a null value');
+        } catch (\TypeError $e) {}
+        try {
+            $member->setPersonId(null);
+            throw new AssertionError('Member::setPersonId should not accept a null value');
+        } catch (\TypeError $e) {}
+
+        // nullable properties
+        $member->setGender(null);
+        $member->setGivenName(null);
+        $member->setName(null);
+        $member->setStartDate(null);
+        $member->setEndDate(null);
+        $member->setInstrumentGroup(null);
+        $member->setInstrument(null);
+        $member->setMission(null);
+        $member->setImage(null);
+        $member->setAddressCity(null);
+        $member->setStreetAddress(null);
+        $member->setStreetAddressSecond(null);
+        $member->setStreetAddressThird(null);
+        $member->setPostalCode(null);
+        $member->setTelphone(null);
+        $member->setMobilPhone(null);
+        $member->setEmail(null);
+
+        // Just to avoid this test to be considered as risky
+        $this->assertEquals(1, 1);
+    }
+
+    /**
+     * getFullName should return the concatenated name of the member,
+     * regarding which informations are available
+     *
+     * @test
+     */
+    public function getFullNameWithNameAndGivenName() {
+        $member = new Member();
+        $member->setGivenName('Benedict');
+        $member->setName('Comcomber');
+        $this->assertEquals(
+            "Benedict Comcomber",
+            $member->getFullName()
+        );
+    }
+
+    /**
+     * getFullName should return the concatenated name of the member,
+     * regarding which informations are available
+     *
+     * @test
+     */
+    public function getFullNameWithNameAndGender() {
+        $member = new Member();
+        $member->setName('Comcomber');
+        $this->assertEquals(
+            "M. Comcomber",
+            $member->getFullName()
+        );
+    }
+
+    /**
+     * getFullName should return the concatenated name of the member,
+     * regarding which informations are available
+     *
+     * @test
+     */
+    public function getFullNameWithNoInformation() {
+        $member = new Member();
+        $this->assertEquals(
+            "",
+            $member->getFullName()
+        );
+    }
+}

+ 100 - 0
ot_core/Tests/Unit/Domain/Model/OrganizationTest.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain;
+
+use AssertionError;
+use Nimut\TestingFramework\TestCase\UnitTestCase;
+use Opentalent\OtCore\Domain\Model\Donor;
+use Opentalent\OtCore\Domain\Model\Organization;
+
+class OrganizationTest extends UnitTestCase
+{
+    /**
+     * Object should instantiate correctly, and properties
+     * set with setters should be retrieved by getters
+     *
+     * @test
+     */
+    public function instantiation() {
+        $organization = new Organization();
+
+        $organization->setId(1);
+        $organization->setType("PortailOrganization");
+        $organization->setSubDomain('subdomain');
+        $organization->setName('name');
+        $organization->setSlug('slug');
+        $organization->setPrincipalType('type');
+        $organization->setDescription('description');
+        $organization->setCategories(['category']);
+        $organization->setAddressCity('city');
+        $organization->setPostalCode('00000');
+        $organization->setStreetAddress('street');
+        $organization->setLatitude(1.2345);
+        $organization->setLongitude(1.2345);
+        $organization->setCountry('country');
+        $organization->setLogo('logo.svg');
+        $organization->setParentId(2);
+        $organization->setParentName('parent');
+        $organization->setParentSubdomain('parent subdomain');
+
+        $this->assertEquals(1, $organization->getId());
+        $this->assertEquals("PortailOrganization", $organization->getType());
+        $this->assertEquals('subdomain', $organization->getSubDomain());
+        $this->assertEquals('name', $organization->getName());
+        $this->assertEquals('slug', $organization->getSlug());
+        $this->assertEquals('type', $organization->getPrincipalType());
+        $this->assertEquals('description', $organization->getDescription());
+        $this->assertEquals(['category'], $organization->getCategories());
+        $this->assertEquals('city', $organization->getAddressCity());
+        $this->assertEquals('00000', $organization->getPostalCode());
+        $this->assertEquals('street', $organization->getStreetAddress());
+        $this->assertEquals(1.2345, $organization->getLatitude());
+        $this->assertEquals(1.2345, $organization->getLongitude());
+        $this->assertEquals('country', $organization->getCountry());
+        $this->assertEquals('logo.svg', $organization->getLogo());
+        $this->assertEquals(2, $organization->getParentId());
+        $this->assertEquals('parent', $organization->getParentName());
+        $this->assertEquals('parent subdomain', $organization->getParentSubdomain());
+    }
+
+    /**
+     * Nullable fields should be able to be set to null,
+     * while non nullable shall raise an exception if set to null
+     *
+     * @test
+     */
+    public function nullableOrNot() {
+
+        $organization = new Organization();
+
+        // non-nullable properties
+        try {
+            $organization->setId(null);
+            throw new AssertionError('Organization::setId should not accept a null value');
+        } catch (\TypeError $e) {}
+
+        // nullable properties
+        $organization->setType(null);
+        $organization->setSubDomain(null);
+        $organization->setName(null);
+        $organization->setSlug(null);
+        $organization->setPrincipalType(null);
+        $organization->setDescription(null);
+        $organization->setCategories(null);
+        $organization->setAddressCity(null);
+        $organization->setPostalCode(null);
+        $organization->setStreetAddress(null);
+        $organization->setLatitude(null);
+        $organization->setLongitude(null);
+        $organization->setCountry(null);
+        $organization->setLogo(null);
+        $organization->setParentId(null);
+        $organization->setParentName(null);
+        $organization->setParentSubdomain(null);
+
+        // Just to avoid this test to be considered as risky
+        $this->assertEquals(1, 1);
+    }
+
+
+}

+ 75 - 0
ot_core/Tests/Unit/Domain/Repository/AbstractApiRepositoryTestCase.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain\Repository;
+
+use GuzzleHttp\Client;
+use Nimut\TestingFramework\TestCase\UnitTestCase;
+use Opentalent\OtCore\Domain\Repository\BaseApiRepository;
+use Opentalent\OtCore\Tests\Unit\Fixtures\ApiResponseFixtures;
+use ReflectionClass;
+use TYPO3\CMS\Core\Core\ApplicationContext;
+
+/**
+ * Base class for BaseApiRepositoryTest and its subclasses
+ *
+ * @package Opentalent\OtCore\Tests\Unit\Repository
+ */
+abstract class AbstractApiRepositoryTestCase extends UnitTestCase
+{
+    const TESTED_CLASS = '';
+
+    protected $context;
+    /**
+     * @var ApiResponseFixtures
+     */
+    protected $fixture;
+    /**
+     * @var object
+     */
+    protected $repository;
+    /**
+     * @var \Prophecy\Prophecy\ObjectProphecy
+     */
+    protected $client;
+
+    public function setUp() {
+        // mock the application context
+        $this->context = $this->prophesize(ApplicationContext::class);
+        $this->context->isProduction()->willReturn(false);
+        $this->context->isDevelopment()->willReturn(false);
+        $this->context->isTesting()->willReturn(true);
+
+        $this->client = $this->prophesize(Client::class);
+
+        $repositoryClass = new ReflectionClass(static::TESTED_CLASS);
+        foreach ($repositoryClass->getMethods() as $method) {
+            $method->setAccessible(true);
+        }
+        $this->repository = $repositoryClass->newInstanceArgs([$this->client->reveal(), $this->context->reveal()]);
+
+        $this->fixture = new ApiResponseFixtures();
+    }
+
+    protected function injectClientFor($uri) {
+        // mock the Guzzle client
+        $willReturn = $this->fixture->get($uri);
+        $client = $this->prophesize(Client::class);
+        $client->request(BaseApiRepository::HTTP_METHOD, $uri)
+            ->shouldBeCalled()
+            ->willReturn($willReturn);
+
+        $reflectionObject = new \ReflectionObject($this->repository);
+        $reflectionMethod = $reflectionObject->getMethod('injectClient');
+        $reflectionMethod->setAccessible(true);
+
+        $reflectionMethod->invokeArgs($this->repository, [$client->reveal()]);
+    }
+
+    protected function callMemberToObject(array $record) {
+        $reflectionObject = new \ReflectionObject($this->repository);
+        $reflectionMethod = $reflectionObject->getMethod('memberToObject');
+        $reflectionMethod->setAccessible(true);
+
+        return $reflectionMethod->invokeArgs($this->repository, [$record]);
+    }
+}

+ 51 - 0
ot_core/Tests/Unit/Domain/Repository/ApiPagedCollectionTest.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Repository;
+
+use Nimut\TestingFramework\TestCase\UnitTestCase;
+use Opentalent\OtCore\Domain\Repository\ApiPagedCollection;
+
+class ApiPagedCollectionTest extends UnitTestCase
+{
+    /**
+     * @test
+     */
+    public function instantiation() {
+        $collection = new ApiPagedCollection(
+            5,
+            2,
+            1,
+            ['a', 'b', 'c', 'd', 'e']
+        );
+        $this->assertEquals(5, $collection->getTotalItems());
+        $this->assertEquals(2, $collection->getItemsPerPage());
+        $this->assertEquals(1, $collection->getCurrentPage());
+        $this->assertEquals(['a', 'b', 'c', 'd', 'e'], $collection->getMembers());
+    }
+
+    /**
+     * @test
+     */
+    public function getLastPage() {
+        $collection = new ApiPagedCollection(
+            5,
+            2,
+            1,
+            ['a', 'b', 'c', 'd', 'e']
+        );
+        $this->assertEquals(3, $collection->getLastPage());
+    }
+
+    /**
+     * @test
+     */
+    public function getPages() {
+        $collection = new ApiPagedCollection(
+            5,
+            2,
+            1,
+            ['a', 'b', 'c', 'd', 'e']
+        );
+        $this->assertEquals(range(1, 3), $collection->getPages());
+    }
+}

+ 165 - 0
ot_core/Tests/Unit/Domain/Repository/BaseApiRepositoryTest.php

@@ -0,0 +1,165 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain\Repository;
+
+use GuzzleHttp\Client;
+use Opentalent\OtCore\Domain\Repository\ApiPagedCollection;
+use Opentalent\OtCore\Domain\Repository\BaseApiRepository;
+use Opentalent\OtCore\Exception\ApiRequestException;
+
+/**
+ * Proxy to reveal the private methods of the abstract class BaseApiRepository
+ * @package Opentalent\OtCore\Tests\Unit\Domain\Repository
+ */
+class ConcreteBaseApiRepository extends BaseApiRepository {
+    protected function memberToObject(array $member) { return $member; }
+    public function injectClient($client) { parent::injectClient($client); }
+    public function getResponse($uri, $params = []): \Psr\Http\Message\ResponseInterface { return parent::getResponse($uri, $params); }
+    public function getBody($uri, $params = []): string { return parent::getBody($uri, $params); }
+    public function getJsonDecoded($uri, $params = []): array { return parent::getJsonDecoded($uri, $params); }
+    public function getApiFirstRecord($params = [], $forceUri = null) { return parent::getApiFirstRecord($params, $forceUri); }
+    public function getApiRecords($params = [], $forceUri = null): ApiPagedCollection { return parent::getApiRecords($params, $forceUri); }
+}
+
+class BaseApiRepositoryTest extends AbstractApiRepositoryTestCase
+{
+    const TESTED_CLASS = 'Opentalent\OtCore\Tests\Unit\Domain\Repository\ConcreteBaseApiRepository';
+
+    /**
+     * get should build a valid url, send a query and
+     * return a Guzzle response object
+     *
+     * @test
+     */
+    public function get() {
+
+        $base_uri = "https://api.opentalent.fr/api/public/organizations";
+        $params = ['itemsPerPage' => 10, 'foo' => 1];
+
+        // uri as it is supposed to be processed by the repo
+        $processed_uri = $base_uri . "?_format=json&itemsPerPage=10&foo=1";
+
+        $this->injectClientFor($processed_uri);
+        $actual = $this->repository->getResponse($base_uri, $params);
+
+        $this->assertEquals(200, $actual->getStatusCode());
+    }
+
+    /**
+     * get should build a valid url, send a query and
+     * return a Guzzle response object
+     *
+     * @test
+     */
+    public function getWithNoParams() {
+
+        $base_uri = "https://api.opentalent.fr/api/public/organizations";
+        $params = [];
+
+        // uri as it is supposed to be processed by the repo
+        $processed_uri = $base_uri . "?_format=json&itemsPerPage=8";
+
+        $this->injectClientFor($processed_uri);
+        $actual = $this->repository->getResponse($base_uri, $params);
+
+        $this->assertEquals(200, $actual->getStatusCode());
+    }
+
+    /**
+     * get should build a valid url, send a query and
+     * return a Guzzle response object
+     *
+     * @test
+     */
+    public function getInvalidUri()
+    {
+        $base_uri = "a very bad uri";
+        $params = [];
+        $processed_uri = $base_uri . "?_format=json&itemsPerPage=8";
+
+        $client = $this->prophesize(Client::class);
+        $client->request(BaseApiRepository::HTTP_METHOD, $processed_uri)
+            ->shouldBeCalled()
+            ->willThrow(new \GuzzleHttp\Exception\TransferException('error'));
+        $this->inject($this->repository, "client", $client->reveal());
+
+        try {
+            $this->repository->getResponse($base_uri, $params);
+            throw new \AssertionError("An ApiRequestException should have been thrown");
+        } catch (ApiRequestException $e) {
+            $this->assertEquals('error', $e->getMessage());
+        }
+    }
+
+    /**
+     * getBody should return the response body as a string
+     *
+     * @test
+     */
+    public function getBody() {
+
+        $base_uri = "https://api.opentalent.fr/api/public/organizations";
+        $params = ['itemsPerPage' => 10, 'foo' => 1];
+
+        // uri as it is supposed to be processed by the repo
+        $processed_uri = $base_uri . "?_format=json&itemsPerPage=10&foo=1";
+
+        $this->injectClientFor($processed_uri);
+        $actual = $this->repository->getBody($base_uri, $params);
+
+        $this->assertEquals('{"@context": "/api/contexts/PortailOrganization"}', $actual);
+    }
+
+    /**
+     * getBody should return the response body as an array
+     *
+     * @test
+     */
+    public function getJsonDecoded() {
+
+        $base_uri = "https://api.opentalent.fr/api/public/organizations";
+        $params = ['itemsPerPage' => 10, 'foo' => 1];
+
+        // uri as it is supposed to be processed by the repo
+        $processed_uri = $base_uri . "?_format=json&itemsPerPage=10&foo=1";
+
+        $this->injectClientFor($processed_uri);
+        $actual = $this->repository->getJsonDecoded($base_uri, $params);
+
+        $this->assertEquals(["@context" => "/api/contexts/PortailOrganization"], $actual);
+    }
+
+    /**
+     * getApiFirstRecord should return the first member of the api response
+     * this member has been processed by the memberToObject method, which does nothing here
+     *
+     * @test
+     */
+    public function getApiFirstRecord() {
+        $base_uri = "https://api.opentalent.fr/api/public/organizations";
+        $params = ['filter[where][id]' => 1];
+        $processed_uri = $base_uri . "?_format=json&filter%5Bwhere%5D%5Bid%5D=1&page=1&totalItems=1&itemsPerPage=8";
+        $this->injectClientFor($processed_uri);
+
+        $actual = $this->repository->getApiFirstRecord($params, $base_uri);
+
+        $this->assertEquals('PortailOrganization', $actual['@type']);
+    }
+
+    /**
+     * getApiFirstRecord should return the members of the api response
+     * these members has been processed by the memberToObject method, which does nothing here
+     *
+     * @test
+     */
+    public function getApiRecords() {
+        $base_uri = "https://api.opentalent.fr/api/public/organizations";
+        $params = ['filter[where][id]' => 1];
+        $processed_uri = $base_uri . "?_format=json&filter%5Bwhere%5D%5Bid%5D=1&itemsPerPage=8";
+        $this->injectClientFor($processed_uri);
+
+        $actual = $this->repository->getApiRecords($params, $base_uri);
+
+        $this->assertEquals('PortailOrganization', $actual->getMembers()[0]['@type']);
+    }
+}

+ 56 - 0
ot_core/Tests/Unit/Domain/Repository/DonorRepositoryTest.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain\Repository;
+
+use Opentalent\OtCore\Domain\Repository\DonorRepository;
+
+
+class DonorRepositoryTest extends AbstractApiRepositoryTestCase
+{
+    const TESTED_CLASS = "Opentalent\OtCore\Domain\Repository\DonorRepository";
+
+    /**
+     * findByOrganizationId should return an ApiPagedCollection object containing
+     * the donor(s) matching the given organizationId
+     *
+     * @test
+     */
+    public function findByOrganizationId() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/donors?_format=json&organizationId=1&page=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findByOrganizationId($organization_id);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * findParentsByOrganizationId should return an ApiPagedCollection object containing
+     * the donor(s) of the parent of the given organization
+     *
+     * @test
+     */
+    public function findParentsByOrganizationId() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/donors?_format=json&organizationId=1&parent=1&page=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findParentsByOrganizationId($organization_id);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * memberToObject should return null if the given
+     * member has not the good type
+     *
+     * @test
+     */
+    public function memberToObjectError() {
+        $this->assertEquals(
+            null,
+            $this->callMemberToObject(['@type' => 'Invalid'])
+        );
+    }
+}

+ 170 - 0
ot_core/Tests/Unit/Domain/Repository/EventRepositoryTest.php

@@ -0,0 +1,170 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain\Repository;
+
+use Opentalent\OtCore\Domain\Repository\EventRepository;
+
+
+class EventRepositoryTest extends AbstractApiRepositoryTestCase
+{
+    const TESTED_CLASS = "Opentalent\OtCore\Domain\Repository\EventRepository";
+
+    /**
+     * findById should return an Event object matching the given id
+     *
+     * @test
+     */
+    public function findById() {
+        $event_id = 2;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/events?_format=json&filter%5Bwhere%5D%5Bid%5D=2&page=1&totalItems=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findById($event_id);
+        $this->assertEquals($event_id, $actual->getId());
+    }
+
+    /**
+     * findByOrganizationId should return an ApiPagedCollection object containing
+     * the event(s) matching the given organizationId
+     *
+     * @test
+     */
+    public function findByOrganizationId() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&page=1&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findByOrganizationId($organization_id);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * findByOrganizationId should return an ApiPagedCollection object containing
+     * the event(s) matching the given organizationId
+     *
+     * @test
+     */
+    public function findByOrganizationIdWithParams() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&page=1&itemsPerPage=1&filter%5Bwhere%5D%5BdatetimeStart%5D%5Bgte%5D=2021-01-01T00%3A00%3A00%2B00%3A00&filter%5Bwhere%5D%5BdatetimeEnd%5D%5Blte%5D=2021-01-31T00%3A00%3A00%2B00%3A00&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findByOrganizationId(
+            $organization_id,
+            new \DateTime('2021-01-01'),
+            new \DateTime('2021-01-31'),
+            $limit=1,
+            $page=1
+        );
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * findParentsByOrganizationId should return an ApiPagedCollection object containing
+     * the event(s) of the parent of the given organization
+     *
+     * @test
+     */
+    public function findParentsByOrganizationId() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&page=1&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&parent=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findParentsByOrganizationId($organization_id);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * findParentsByOrganizationId should return an ApiPagedCollection object containing
+     * the event(s) of the parent of the given organization
+     *
+     * @test
+     */
+    public function findParentsByOrganizationIdWithParams() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&page=1&itemsPerPage=1&filter%5Bwhere%5D%5BdatetimeStart%5D%5Bgte%5D=2021-01-01T00%3A00%3A00%2B00%3A00&filter%5Bwhere%5D%5BdatetimeEnd%5D%5Blte%5D=2021-01-31T00%3A00%3A00%2B00%3A00&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&parent=1";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findParentsByOrganizationId(
+            $organization_id,
+            new \DateTime('2021-01-01'),
+            new \DateTime('2021-01-31'),
+            $limit=1,
+            $page=1
+        );
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * findParentsByOrganizationId should return an ApiPagedCollection object containing
+     * the event(s) of the parent of the given organization
+     *
+     * @test
+     */
+    public function findChildrenByOrganizationId() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&children=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findChildrenByOrganizationId($organization_id);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * findParentsByOrganizationId should return an ApiPagedCollection object containing
+     * the event(s) of the parent of the given organization
+     *
+     * @test
+     */
+    public function findChildrenByOrganizationIdWithParams() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&itemsPerPage=1&filter%5Bwhere%5D%5BdatetimeStart%5D%5Bgte%5D=2021-01-01T00%3A00%3A00%2B00%3A00&filter%5Bwhere%5D%5BdatetimeEnd%5D%5Blte%5D=2021-01-31T00%3A00%3A00%2B00%3A00&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&children=1";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findChildrenByOrganizationId(
+            $organization_id,
+            new \DateTime('2021-01-01'),
+            new \DateTime('2021-01-31'),
+            $limit=1,
+            $page=1
+        );
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * searchBy should return an ApiPagedCollection object containing
+     * the event(s) matching the criteria
+     *
+     * @test
+     */
+    public function searchBy() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&filter%5Bwhere%5D%5Bid%5D=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->searchBy($organization_id, ['filter[where][id]' => 1]);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * memberToObject should return null if the given
+     * member has not the good type
+     *
+     * @test
+     */
+    public function memberToObjectError() {
+        $this->assertEquals(
+            null,
+            $this->callMemberToObject(['@type' => 'Invalid'])
+        );
+    }
+
+}

+ 56 - 0
ot_core/Tests/Unit/Domain/Repository/MemberRepositoryTest.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain\Repository;
+
+use Opentalent\OtCore\Domain\Repository\MemberRepository;
+
+
+class MemberRepositoryTest extends AbstractApiRepositoryTestCase
+{
+    const TESTED_CLASS = "Opentalent\OtCore\Domain\Repository\MemberRepository";
+
+    /**
+     * findByOrganizationId should return an ApiPagedCollection object containing
+     * the member(s) matching the given organizationId
+     *
+     * @test
+     */
+    public function findByOrganizationId() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/members?_format=json&filter%5Bwhere%5D%5BorganizationId%5D=1&itemsPerPage=200";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findByOrganizationId($organization_id);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * findByOrganizationId should return an ApiPagedCollection object containing
+     * the member(s) matching the given organizationId
+     *
+     * @test
+     */
+    public function findByOrganizationIdWithCa() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/members_ca?_format=json&filter%5Bwhere%5D%5BorganizationId%5D=1&itemsPerPage=200";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findByOrganizationId($organization_id, true);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getOrganizationId());
+    }
+
+    /**
+     * memberToObject should return null if the given
+     * member has not the good type
+     *
+     * @test
+     */
+    public function memberToObjectError() {
+        $this->assertEquals(
+            null,
+            $this->callMemberToObject(['@type' => 'Invalid'])
+        );
+    }
+}

+ 84 - 0
ot_core/Tests/Unit/Domain/Repository/OrganizationRepositoryTest.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Domain\Repository;
+
+use Opentalent\OtCore\Domain\Repository\OrganizationRepository;
+
+
+class OrganizationRepositoryTest extends AbstractApiRepositoryTestCase
+{
+    const TESTED_CLASS = "Opentalent\OtCore\Domain\Repository\OrganizationRepository";
+
+    /**
+     * findById should return an the Organization matching the given id
+     *
+     * @test
+     */
+    public function findById() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bid%5D=1&page=1&totalItems=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findById($organization_id);
+        $this->assertEquals($organization_id, $actual->getId());
+    }
+
+    /**
+     * findById should return an the Organization matching the given id
+     *
+     * @test
+     */
+    public function findByName() {
+        $name = 'a name';
+
+        $expected_uri = "https://api.opentalent.fr/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bname%5D=a+name&page=1&totalItems=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findByName($name);
+        $this->assertEquals(1, $actual->getId());
+    }
+
+    /**
+     * findById should return an the Organization matching the given id
+     *
+     * @test
+     */
+    public function findByInexistantName() {
+        $name = 'a unknown name';
+
+        $expected_uri = "https://api.opentalent.fr/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bname%5D=a+unknown+name&page=1&totalItems=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findByName($name);
+        $this->assertEquals(1, $actual->getId());
+    }
+
+    /**
+     * findById should return an the Organization matching the given id
+     *
+     * @test
+     */
+    public function findChildrenById() {
+        $organization_id = 1;
+
+        $expected_uri = "https://api.opentalent.fr/api/public/organizations?_format=json&parentId=1&children=1&page=1&itemsPerPage=8";
+        $this->injectClientFor($expected_uri);
+
+        $actual = $this->repository->findChildrenById($organization_id);
+        $this->assertEquals($organization_id, $actual->getMembers()[0]->getId());
+    }
+
+    /**
+     * memberToObject should return null if the given
+     * member has not the good type
+     *
+     * @test
+     */
+    public function memberToObjectError() {
+        $this->assertEquals(
+            null,
+            $this->callMemberToObject(['@type' => 'Invalid'])
+        );
+    }
+}

+ 26 - 0
ot_core/Tests/Unit/Exception/ApiRequestExceptionTest.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Opentalent\OtCore\Tests\Unit\Exception;
+
+use Exception;
+use Nimut\TestingFramework\TestCase\UnitTestCase;
+use Opentalent\OtCore\Exception\ApiRequestException;
+
+class ApiRequestExceptionTest extends UnitTestCase
+{
+    /**
+     * Construction from another exception
+     *
+     * @test
+     */
+    public function from_exception() {
+
+        $e = new Exception("msg", 1, new Exception());
+
+        $result = ApiRequestException::from_exception($e);
+
+        $this->assertEquals($result->getMessage(), $result->getMessage());
+        $this->assertEquals($result->getCode(), $result->getCode());
+        $this->assertEquals($result->getPrevious(), $result->getPrevious());
+    }
+}

+ 198 - 0
ot_core/Tests/Unit/Fixtures/ApiResponseFixtures.php

@@ -0,0 +1,198 @@
+<?php
+
+
+namespace Opentalent\OtCore\Tests\Unit\Fixtures;
+
+
+use TYPO3\CMS\Core\Http\HtmlResponse;
+
+
+/**
+ * This class return responses that mime the Opentalent API
+ *
+ * @package Opentalent\OtCore\Tests\Unit\Fixtures
+ */
+class ApiResponseFixtures
+{
+    private $responses = [
+        'stub' => '{"@context": "/api/contexts/PortailOrganization"}',
+        'org' => '
+        {
+            "@context": "/api/contexts/PortailOrganization",
+            "@id": "/api/public/organizations?_format=json&filter[where][id]=1",
+            "@type": "hydra:PagedCollection",
+            "hydra:totalItems": 1,
+            "hydra:itemsPerPage": 30,
+            "hydra:firstPage": "/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bid%5D=1",
+            "hydra:lastPage": "/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bid%5D=1",
+            "hydra:member": [
+                {
+                    "@id": "/api/public/organizations/1",
+                    "@type": "PortailOrganization",
+                    "subDomain": "org",
+                    "name": "Name",
+                    "slug": "org-1",
+                    "principalType": "ARTISTIC_PRACTICE_ONLY",
+                    "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi faucibus nulla et nulla maximus tempor. Nullam eros nunc, sodales nec turpis vel, semper consectetur eros. Vivamus id porttitor turpis. Cras at urna faucibus, cursus eros ac, eleifend enim. Maecenas id leo id ante lacinia tempor. Cras est eros, pellentesque et luctus non, tempor in turpis. Maecenas interdum, nisi eu posuere vestibulum, ex erat accumsan sem, in tempor sem mi scelerisque sapien. ",
+                    "categories": [
+                        "1MC"
+                    ],
+                    "addressCity": "CITY",
+                    "postalCode": "00000",
+                    "streetAddress": "street",
+                    "latitude": 1.2345,
+                    "longitude": 1.2345,
+                    "country": "France",
+                    "logo": "https://api.opentalent.fr/_internal/secure/files/1",
+                    "parentName": "Network",
+                    "parentSubDomain": "network"
+                }],
+        "hydra:search": {}
+        }',
+        'event' => '
+        {
+            "@context": "/api/contexts/PortailEvent",
+            "@id": "/api/public/events?_format=json&filter[where][id]=2",
+            "@type": "hydra:PagedCollection",
+            "hydra:totalItems": 1,
+            "hydra:itemsPerPage": 30,
+            "hydra:firstPage": "/api/public/events?_format=json&filter%5Bwhere%5D%5Bid%5D=2",
+            "hydra:lastPage": "/api/public/events?_format=json&filter%5Bwhere%5D%5Bid%5D=2",
+            "hydra:member": [
+                {
+                    "@id": "/api/public/events/2",
+                    "@type": "PortailEvent",
+                    "organizationId": 1,
+                    "subDomain": "org",
+                    "name": "The Event",
+                    "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi faucibus nulla et nulla maximus tempor. Nullam eros nunc, sodales nec turpis vel, semper consectetur eros. Vivamus id porttitor turpis. Cras at urna faucibus, cursus eros ac, eleifend enim. Maecenas id leo id ante lacinia tempor. Cras est eros, pellentesque et luctus non, tempor in turpis. Maecenas interdum, nisi eu posuere vestibulum, ex erat accumsan sem, in tempor sem mi scelerisque sapien. ",
+                    "categories": [],
+                    "url": "http://www.org.fr/",
+                    "rule": null,
+                    "datetimeStart": "2021-01-01T17:00:00+00:00",
+                    "datetimeEnd": "2021-01-01T18:00:00+00:00",
+                    "dates": null,
+                    "placeName": "Place",
+                    "placeDescription": null,
+                    "placeFloorSize": null,
+                    "placeCapacity": null,
+                    "city": "City",
+                    "postalCode": "00000",
+                    "streetAddress": "street",
+                    "longitude": 1.2345,
+                    "latitude": 1.2345,
+                    "roomName": null,
+                    "roomDescription": null,
+                    "roomLocalisation": null,
+                    "roomCapacity": null,
+                    "roomFloorSize": null,
+                    "zupId": null,
+                    "deepLink": null,
+                    "image": "https://api.opentalent.fr/_internal/secure/files/2",
+                    "priceMini": null,
+                    "meetingSchedule": [],
+                    "api": true,
+                    "parentName": "Network",
+                    "parentSubDomain": "network"
+                }
+        ],
+        "hydra:search":{}
+        }',
+        'donor' => '
+        {
+            "@context": "/api/contexts/PortailDonor",
+            "@id": "/api/public/donors?_format=json&organizationId=1",
+            "@type": "hydra:PagedCollection",
+            "hydra:totalItems": 1,
+            "hydra:itemsPerPage": 30,
+            "hydra:firstPage": "/api/public/donors?_format=json&organizationId=1",
+            "hydra:lastPage": "/api/public/donors?_format=json&organizationId=1",
+            "hydra:member": [
+                {
+                    "@id": "/api/public/donors/3",
+                    "@type": "PortailDonor",
+                    "organizationId": 1,
+                    "name": "Donor",
+                    "email": "email@domain.com",
+                    "telphone": "0000000000",
+                    "website": "www.website.com",
+                    "wording": null,
+                    "displayedOn": "NONE",
+                    "logo": "https://api.opentalent.fr/_internal/secure/files/1"
+                }
+            ],
+            "hydra:search": {}
+        }',
+        'member' => '
+        {
+            "@context": "/api/contexts/PortailMemberBySpeciality",
+            "@id": "/api/public/members?_format=json&filter[where][id]=4",
+            "@type": "hydra:PagedCollection",
+            "hydra:totalItems": 1,
+            "hydra:itemsPerPage": 30,
+            "hydra:firstPage": "/api/public/members?_format=json&filter%5Bwhere%5D%5Bid%5D=4",
+            "hydra:lastPage": "/api/public/members?_format=json&filter%5Bwhere%5D%5Bid%5D=4",
+            "hydra:member": [
+                {
+                    "@id": "/api/public/members/5",
+                    "@type": "PortailMemberBySpeciality",
+                    "organizationId": 1,
+                    "gender": "MISS",
+                    "givenName": "Marie-Aiglantine",
+                    "name": "De Saint-Marcellin",
+                    "startDate": "2010-01-01T00:00:00+00:00",
+                    "endDate": null,
+                    "instrumentGroup": "OBOE",
+                    "instrument": "OBOE",
+                    "personId": 4,
+                    "image": "https://api.opentalent.fr/_internal/secure/files/1"
+                }
+            ],
+            "hydra:search": {}
+        }'
+    ];
+
+    /**
+     * Do the mapping between URI and responses
+     * /!\ Each of these uri shall be tested against the Opentalent API. Without
+     *     this, those tests won't prove anything...
+     *
+     * @var string[]
+     */
+    private $map = [
+        'https://api.opentalent.fr/api/public/organizations?_format=json&itemsPerPage=10&foo=1' => 'stub',
+        'https://api.opentalent.fr/api/public/organizations?_format=json&itemsPerPage=8' => 'stub',
+        'https://api.opentalent.fr/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bid%5D=1&page=1&totalItems=1&itemsPerPage=8' => 'org',
+        'https://api.opentalent.fr/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bname%5D=a+name&page=1&totalItems=1&itemsPerPage=8' => 'org',
+        'https://api.opentalent.fr/api/public/organizations?_format=json&parentId=1&children=1&page=1&itemsPerPage=8' => 'org',
+        'https://api.opentalent.fr/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bid%5D=1&itemsPerPage=8' => 'org',
+        'https://api.opentalent.fr/api/public/organizations?_format=json&filter%5Bwhere%5D%5Bname%5D=a+unknown+name&page=1&totalItems=1&itemsPerPage=8' => 'org',
+        'https://api.opentalent.fr/api/public/events?_format=json&filter%5Bwhere%5D%5Bid%5D=2&page=1&totalItems=1&itemsPerPage=8' => 'event',
+        'https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&page=1&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&itemsPerPage=8' => 'event',
+        'https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&page=1&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&parent=1&itemsPerPage=8' => 'event',
+        'https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&children=1&itemsPerPage=8' => 'event',
+        'https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&filter%5Bwhere%5D%5Bid%5D=1&itemsPerPage=8' => 'event',
+        'https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&page=1&itemsPerPage=1&filter%5Bwhere%5D%5BdatetimeStart%5D%5Bgte%5D=2021-01-01T00%3A00%3A00%2B00%3A00&filter%5Bwhere%5D%5BdatetimeEnd%5D%5Blte%5D=2021-01-31T00%3A00%3A00%2B00%3A00&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC' => 'event',
+        'https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&page=1&itemsPerPage=1&filter%5Bwhere%5D%5BdatetimeStart%5D%5Bgte%5D=2021-01-01T00%3A00%3A00%2B00%3A00&filter%5Bwhere%5D%5BdatetimeEnd%5D%5Blte%5D=2021-01-31T00%3A00%3A00%2B00%3A00&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&parent=1' => 'event',
+        'https://api.opentalent.fr/api/public/events?_format=json&organizationId=1&itemsPerPage=1&filter%5Bwhere%5D%5BdatetimeStart%5D%5Bgte%5D=2021-01-01T00%3A00%3A00%2B00%3A00&filter%5Bwhere%5D%5BdatetimeEnd%5D%5Blte%5D=2021-01-31T00%3A00%3A00%2B00%3A00&filter%5Border%5D%5B0%5D%5BdatetimeStart%5D=ASC&children=1' => 'event',
+        'https://api.opentalent.fr/api/public/donors?_format=json&organizationId=1&page=1&itemsPerPage=8' => 'donor',
+        'https://api.opentalent.fr/api/public/donors?_format=json&organizationId=1&parent=1&page=1&itemsPerPage=8' => 'donor',
+        'https://api.opentalent.fr/api/public/members?_format=json&filter%5Bwhere%5D%5BorganizationId%5D=1&itemsPerPage=200' => 'member',
+        'https://api.opentalent.fr/api/public/members_ca?_format=json&filter%5Bwhere%5D%5BorganizationId%5D=1&itemsPerPage=200' => 'member'
+    ];
+
+    public function get($url) {
+        if (array_key_exists($url, $this->map)) {
+            $query = $this->map[$url];
+            $response = new HtmlResponse(
+                $this->responses[$query],
+                200,
+                []
+            );
+            return $response;
+        } else {
+            return null;
+        }
+    }
+
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 0
ot_core/Tests/Unit/Fixtures/PageFixtures.php


Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff