diff --git a/.env.test b/.env.test index 181af84..d588890 100755 --- a/.env.test +++ b/.env.test @@ -1,6 +1,10 @@ +###> symfony/framework-bundle ### +APP_ENV=test +APP_SECRET='$ecretf0rt3st' +###< symfony/framework-bundle ### + # define your env variables for the test env here KERNEL_CLASS='App\Kernel' -APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots @@ -11,9 +15,9 @@ PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots DATABASE_HOST=postgres DATABASE_PORT=5432 DATABASE_NAME=larpilot -DATABASE_USER=postgres +DATABASE_USER=larpilot DATABASE_PASSWORD=password -DATABASE_SERVER_VERSION=13 +DATABASE_SERVER_VERSION=15 # Messenger & Mailer (test stubs) MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml deleted file mode 100755 index 0d66968..0000000 --- a/.github/workflows/phpunit.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: PHPUnit - -on: - pull_request: - branches: [ "**" ] - push: - branches: [ main, master ] - -jobs: - tests: - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:15 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: larpilot_test - ports: - - "5432:5432" - options: >- - --health-cmd="pg_isready -U postgres -d larpilot_test" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - - steps: - - uses: actions/checkout@v4 - - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - coverage: none - tools: composer:v2 - - - name: Copy .env.test if needed - run: | - cp -n .env.test.dist .env.test || true - - - name: Install dependencies - run: composer install --no-interaction --prefer-dist - - - name: Wait for PostgreSQL - run: | - for i in `seq 1 30`; do - pg_isready -h 127.0.0.1 -p 5432 -U postgres -d larpilot_test && break - sleep 1 - done - - - name: Prepare test database - env: - APP_ENV: test - run: | - php bin/console doctrine:database:create --if-not-exists --env=test - php bin/console doctrine:migrations:migrate --no-interaction --env=test - - - name: Prepare Frontend - env: - APP_ENV: test - run: | - php bin/console importmap:install - php bin/console sass:build || true - php bin/console asset-map:compile - composer dump-autoload --optimize - - - name: Run tests - env: - APP_ENV: test - run: vendor/bin/phpunit --colors=always \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba6583d..bffe4a3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,9 +56,9 @@ jobs: postgres: image: postgres:15 env: - POSTGRES_USER: larpilot_test - POSTGRES_PASSWORD: larpilot_test - POSTGRES_DB: larpilot_test + POSTGRES_USER: larpilot + POSTGRES_PASSWORD: password + POSTGRES_DB: larpilot options: >- --health-cmd pg_isready --health-interval 10s @@ -94,75 +94,37 @@ jobs: - name: Setup test environment run: | - cp .env .env.test.local || true - echo "APP_ENV=test" >> .env.test.local - echo "DATABASE_URL=postgresql://larpilot_test:larpilot_test@127.0.0.1:5432/larpilot_test?serverVersion=15&charset=utf8" >> .env.test.local + # Create .env.test.local to override .env.test values for GitHub Actions + echo "DATABASE_HOST=127.0.0.1" > .env.test.local - name: Create test database + env: + DATABASE_HOST: 127.0.0.1 run: | php bin/console doctrine:database:create --env=test --if-not-exists php bin/console doctrine:migrations:migrate --env=test --no-interaction + - name: Build assets (SASS) + env: + DATABASE_HOST: 127.0.0.1 + run: | + php bin/console sass:build --env=test + php bin/console asset-map:compile --env=test + - name: Build Codeception Actors run: vendor/bin/codecept build - - name: Run All Codeception Tests + - name: Run Codeception Tests + env: + DATABASE_HOST: 127.0.0.1 run: | APP_ENV=test vendor/bin/codecept run --colors - - name: Run Unit Tests Only - run: | - APP_ENV=test vendor/bin/codecept run unit --colors - - - name: Run Functional Tests Only - run: | - APP_ENV=test vendor/bin/codecept run functional --colors - - docker-tests: - name: Docker Environment Tests - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Copy .env file - run: cp .env .env.local || true - - - name: Build Docker containers - run: docker compose build - - - name: Start Docker containers - run: docker compose up -d - - - name: Wait for services to be ready - run: | - sleep 30 - docker compose ps - - - name: Install dependencies in container - run: docker compose exec -T php composer install --no-interaction - - - name: Run migrations in container - run: | - docker compose exec -T php php bin/console doctrine:database:create --env=test --if-not-exists - docker compose exec -T php php bin/console doctrine:migrations:migrate --env=test --no-interaction - - - name: Build Codeception Actors in container - run: docker compose exec -T php vendor/bin/codecept build - - - name: Run Codeception tests in Docker container - run: | - docker compose exec -T php bash -lc "APP_ENV=test vendor/bin/codecept run --colors" - - - name: Stop Docker containers - if: always() - run: docker compose down summary: name: Test Summary runs-on: ubuntu-latest - needs: [code-quality, tests, docker-tests] + needs: [code-quality, tests] if: always() steps: @@ -176,8 +138,4 @@ jobs: echo "Codeception tests failed" exit 1 fi - if [ "${{ needs.docker-tests.result }}" != "success" ]; then - echo "Docker tests failed" - exit 1 - fi echo "All tests passed successfully!" diff --git a/.gitignore b/.gitignore index df19e7c..88f7c25 100755 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,6 @@ /vendor/ ###< symfony/framework-bundle ### -###> phpunit/phpunit ### -/phpunit.xml -.phpunit.result.cache -###< phpunit/phpunit ### - ###> symfony/asset-mapper ### /public/assets/ /public/uploads/maps/* diff --git a/AGENTS.md b/AGENTS.md index 371858f..607d589 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,9 @@ - JavaScript is written as ES6 modules and organized under `assets/controllers/` using Stimulus. Use 4 space indentation and define `targets`, `values`, and life‑cycle methods (e.g., `connect`). Webpack Encore bundles assets and the entry point is `assets/app.js`. ## Tests and checks -- Run unit tests with `vendor/bin/phpunit -c phpunit.xml.dist`. -- Run code style checks with `vendor/bin/ecs check`. -- Run static analysis with `vendor/bin/phpstan analyse -c phpstan.neon`. +- Prepare tests with `make test-build`. +- Run unit tests with `make test`. +- Run code style checks with `make ecs-fix`. +- Run static analysis with `make stan`. Make sure these checks pass before committing changes. diff --git a/CLAUDE.md b/CLAUDE.md index 3fa5152..781da08 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,9 +40,21 @@ make ecs-fix make stan # OR: docker compose exec -T php bash -lc "XDEBUG_MODE=off php -d memory_limit=-1 vendor/bin/phpstan analyse -c phpstan.neon" -# Run tests +# Run all tests (Codeception) make test -# OR: docker compose exec -T php bash -lc "APP_ENV=test php vendor/bin/phpunit -c phpunit.xml.dist --colors=always" +# OR: docker compose exec -T php vendor/bin/codecept run --colors + +# Run specific test suites +make test-unit # Unit tests only (fast, no database) +make test-functional # Functional tests (with database, no browser) +make test-acceptance # Acceptance tests (browser-based) + +# Run a specific test file +make test-filter FILTER=Functional/Authentication/UserSignupAndApprovalCest + +# Rebuild Codeception actors (after suite config changes) +make test-build +# OR: docker compose exec -T php vendor/bin/codecept build # Automated refactoring (PHP 8.2) make rector-fix @@ -377,7 +389,7 @@ $pagination = $this->getPagination($qb, $request); For sort controls in filter forms, use twig since they're UI controls, not filters: ```php -{% include 'includes/sort_th.html.twig' with { field: 'name', label: 'common.name'|trans } %} +{% include 'includes/sort_th.html.twig' with { field: 'name', label: 'name'|trans } %} ``` **Examples**: See `FactionController::list()` for complete implementations. @@ -396,7 +408,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter
@@ -414,10 +426,10 @@ Backoffice list pages follow a consistent template pattern for displaying filter {# Sortable column header #} {% include 'includes/sort_th.html.twig' with { field: 'name', - label: 'common.name'|trans + label: 'name'|trans } %} -{{ 'common.empty_list'|trans }}
+{{ 'empty_list'|trans }}
{% endif %} @@ -470,7 +482,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter 3. **Conditional Data Display**: - Check `{% if items is not empty %}` before rendering table - - Show `{{ 'common.empty_list'|trans }}` message when no data + - Show `{{ 'empty_list'|trans }}` message when no data 4. **Table Styling**: - `table-responsive` wrapper for mobile scrolling @@ -488,7 +500,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter - Pass item data via `data-*` attributes for JavaScript handling 7. **Empty State**: - - Simple text message: `{{ 'common.empty_list'|trans }}
` + - Simple text message: `{{ 'empty_list'|trans }}
` **Sorting Implementation**: @@ -528,13 +540,134 @@ Copy `.env` to `.env.local` and configure: ## Testing -Run tests with PHPUnit: +LARPilot uses **Codeception** for all testing (unit, functional, and acceptance tests). + +### Running Tests + ```bash -vendor/bin/phpunit -c phpunit.xml.dist +# Run all tests +make test + +# Run specific test suites +make test-unit # Unit tests only (fast, no database) +make test-functional # Functional tests (with database, no browser) +make test-acceptance # Acceptance tests (browser-based) + +# Run a specific test file or path +make test-filter FILTER=Functional/Authentication/UserSignupAndApprovalCest +make test-filter FILTER=Unit/Domain/Infrastructure + +# Rebuild actors after suite configuration changes +make test-build ``` +### Test Structure + +``` +tests/ +├── Unit/ # Unit tests (no dependencies) +├── Functional/ # Functional tests (with database) +├── Acceptance/ # Browser-based tests +└── Support/ + ├── Helper/ + │ └── Authentication.php # Custom Codeception helper + └── Factory/ # Foundry factories (organized by domain) + ├── Account/ + ├── Core/ + └── Survey/ +``` + +### Writing Tests + +**Functional Test Example** (Cest format): +```php +wantTo('verify PENDING users cannot access backoffice'); + + $pendingUser = $I->createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('backoffice_larp_create'); + $I->seeResponseCodeIs(302); + } +} +``` + +### Authentication Helper Methods + +The `Authentication` helper (`tests/Support/Helper/Authentication.php`) provides factory-based methods for test data: + +**User Creation:** +- `$I->createPendingUser()` - Create PENDING user +- `$I->createApprovedUser()` - Create APPROVED user +- `$I->createSuspendedUser()` - Create SUSPENDED user +- `$I->createBannedUser()` - Create BANNED user +- `$I->createSuperAdmin()` - Create SUPER_ADMIN user + +**Authentication:** +- `$I->amLoggedInAs($user)` - Log in as specific user + +**LARP Creation:** +- `$I->createLarp($organizer)` - Create LARP (default: DRAFT) +- `$I->createDraftLarp($organizer)` - Create DRAFT LARP +- `$I->createPublishedLarp($organizer)` - Create PUBLISHED LARP +- `$I->addParticipantToLarp($larp, $user, 'player')` - Add participant + +**Location Creation:** +- `$I->createPendingLocation($creator)` - Create PENDING location +- `$I->createApprovedLocation($creator)` - Create APPROVED location +- `$I->createRejectedLocation($creator, $reason)` - Create REJECTED location + +**Utilities:** +- `$I->getEntityManager()` - Get Doctrine EntityManager +- `$I->getUrl('route_name', $params)` - Generate URL from route + +### Foundry Factories + +Tests use **Zenstruck Foundry** factories for test data creation. Factories are organized by domain under `tests/Support/Factory/`: + +**Example Usage:** +```php +use Tests\Support\Factory\Account\UserFactory; +use Tests\Support\Factory\Core\LarpFactory; +use Tests\Support\Factory\Survey\SurveyFactory; + +// Create user with factory +$user = UserFactory::new()->approved()->create(); + +// Create LARP with factory +$larp = LarpFactory::new() + ->forOrganizer($user) + ->withStatus('published') + ->create(); + +// Create survey with questions +$survey = SurveyFactory::new() + ->forLarp($larp) + ->withQuestions(5) + ->create(); +``` + +See factory classes for available methods and options. + +### Test Database + Test database uses suffix `_test` (configured in `config/packages/doctrine.yaml`). +**Prepare test database:** +```bash +make prepare-test-db +``` + ## Code Quality Standards - **PHP Version**: 8.2+ diff --git a/assets/controllers/application-mode-toggle_controller.js b/assets/controllers/application-mode-toggle_controller.js new file mode 100644 index 0000000..04445ad --- /dev/null +++ b/assets/controllers/application-mode-toggle_controller.js @@ -0,0 +1,76 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Application Mode Toggle Controller + * + * Manages the visibility of the "Publish Characters Publicly" checkbox + * based on the selected application mode in LARP settings. + * + * - CHARACTER_SELECTION mode: Shows the checkbox + * - SURVEY mode: Hides the checkbox and unchecks it + */ +export default class extends Controller { + static targets = ['publishCheckbox']; + + connect() { + this.updateVisibility(); + } + + /** + * Called when application mode radio buttons change + */ + change(event) { + this.updateVisibility(); + } + + updateVisibility() { + const selectedMode = this.getSelectedMode(); + const publishContainer = this.findPublishCheckboxContainer(); + + if (!publishContainer) { + return; + } + + if (selectedMode === 'character_selection') { + // Show the checkbox for character selection mode + publishContainer.style.display = 'block'; + } else if (selectedMode === 'survey') { + // Hide the checkbox for survey mode and uncheck it + publishContainer.style.display = 'none'; + + // Uncheck the checkbox + const checkbox = publishContainer.querySelector('input[type="checkbox"]'); + if (checkbox) { + checkbox.checked = false; + } + } + } + + getSelectedMode() { + // Find the checked radio button + const checkedRadio = this.element.querySelector('input[type="radio"]:checked'); + return checkedRadio ? checkedRadio.value : null; + } + + findPublishCheckboxContainer() { + // The checkbox is in a sibling form group + // Look for the parent form and then find the publish checkbox container + const form = this.element.closest('form'); + if (!form) { + return null; + } + + // Find by data attribute if available + if (this.hasPublishCheckboxTarget) { + return this.publishCheckboxTarget.closest('.mb-3, .form-group'); + } + + // Fallback: Find by field name pattern + const publishField = form.querySelector('input[name*="publishCharactersPublicly"]'); + if (publishField) { + return publishField.closest('.mb-3, .form-group'); + } + + return null; + } +} diff --git a/assets/controllers/character-gallery-card_controller.js b/assets/controllers/character-gallery-card_controller.js new file mode 100644 index 0000000..e92b0f2 --- /dev/null +++ b/assets/controllers/character-gallery-card_controller.js @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Character Gallery Card Controller + * + * Handles interactions for character cards in the public gallery. + * Currently provides hover effects and could be extended for lazy loading, + * favoriting, or other interactive features. + */ +export default class extends Controller { + static targets = ['image', 'card']; + + connect() { + this.addHoverEffects(); + } + + addHoverEffects() { + if (this.hasCardTarget) { + this.cardTarget.addEventListener('mouseenter', () => { + this.cardTarget.classList.add('shadow-lg'); + }); + + this.cardTarget.addEventListener('mouseleave', () => { + this.cardTarget.classList.remove('shadow-lg'); + }); + } + } + + /** + * Navigate to character detail page + * Can be used for card click handling if needed + */ + viewDetails(event) { + // Prevent navigation if clicking on a link inside the card + if (event.target.tagName === 'A' || event.target.closest('a')) { + return; + } + + const detailUrl = this.element.dataset.detailUrl; + if (detailUrl) { + window.location.href = detailUrl; + } + } + + /** + * Lazy load image when card enters viewport + * Usage: data-action="intersection@window->character-gallery-card#lazyLoadImage" + */ + lazyLoadImage() { + if (this.hasImageTarget && this.imageTarget.dataset.src) { + this.imageTarget.src = this.imageTarget.dataset.src; + delete this.imageTarget.dataset.src; + } + } +} diff --git a/assets/controllers/map-config-preview_controller.js b/assets/controllers/map-config-preview_controller.js new file mode 100644 index 0000000..0632463 --- /dev/null +++ b/assets/controllers/map-config-preview_controller.js @@ -0,0 +1,290 @@ +import { Controller } from '@hotwired/stimulus'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.min.css'; + +/** + * Map Configuration Preview Controller + * + * Provides real-time preview of map image with configurable grid overlay. + * Allows users to see changes before saving. + */ +export default class extends Controller { + static targets = [ + 'preview', // The map container div + 'fileInput', // File input for new image + 'gridRows', // Grid rows input + 'gridColumns', // Grid columns input + 'gridOpacity', // Grid opacity input + 'gridVisible', // Grid visibility checkbox + 'placeholder' // Placeholder shown when no image + ]; + + static values = { + existingImage: String, // URL of existing image (for edit mode) + gridRows: { type: Number, default: 10 }, + gridColumns: { type: Number, default: 10 }, + gridOpacity: { type: Number, default: 0.5 }, + gridVisible: { type: Boolean, default: true } + }; + + connect() { + this.map = null; + this.gridLayer = null; + this.imageOverlay = null; + this.blobUrl = null; + + // Initialize with existing image if available + if (this.existingImageValue) { + this.loadImage(this.existingImageValue); + } + + // Sync initial values from form inputs + this.syncFromInputs(); + } + + disconnect() { + this.cleanup(); + } + + cleanup() { + if (this.map) { + this.map.remove(); + this.map = null; + } + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + this.blobUrl = null; + } + } + + /** + * Sync grid values from form inputs + */ + syncFromInputs() { + if (this.hasGridRowsTarget) { + this.gridRowsValue = parseInt(this.gridRowsTarget.value) || 10; + } + if (this.hasGridColumnsTarget) { + this.gridColumnsValue = parseInt(this.gridColumnsTarget.value) || 10; + } + if (this.hasGridOpacityTarget) { + this.gridOpacityValue = parseFloat(this.gridOpacityTarget.value) || 0.5; + } + if (this.hasGridVisibleTarget) { + this.gridVisibleValue = this.gridVisibleTarget.checked; + } + } + + /** + * Handle file input change - create blob URL and load preview + */ + onFileChange(event) { + const file = event.target.files[0]; + if (!file) return; + + // Validate file type + if (!file.type.startsWith('image/')) { + console.warn('Selected file is not an image'); + return; + } + + // Revoke previous blob URL + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + } + + // Create new blob URL for preview + this.blobUrl = URL.createObjectURL(file); + this.loadImage(this.blobUrl); + } + + /** + * Handle grid rows input change + */ + onGridRowsChange(event) { + const value = parseInt(event.target.value); + if (value > 0 && value <= 100) { + this.gridRowsValue = value; + this.redrawGrid(); + } + } + + /** + * Handle grid columns input change + */ + onGridColumnsChange(event) { + const value = parseInt(event.target.value); + if (value > 0 && value <= 100) { + this.gridColumnsValue = value; + this.redrawGrid(); + } + } + + /** + * Handle grid opacity input change + */ + onGridOpacityChange(event) { + const value = parseFloat(event.target.value); + if (value >= 0 && value <= 1) { + this.gridOpacityValue = value; + this.redrawGrid(); + } + } + + /** + * Handle grid visibility checkbox change + */ + onGridVisibleChange(event) { + this.gridVisibleValue = event.target.checked; + this.redrawGrid(); + } + + /** + * Load image and initialize map + */ + loadImage(imageUrl) { + // Clean up existing map + if (this.map) { + this.map.remove(); + this.map = null; + } + + // Show preview, hide placeholder + if (this.hasPlaceholderTarget) { + this.placeholderTarget.classList.add('d-none'); + } + if (this.hasPreviewTarget) { + this.previewTarget.classList.remove('d-none'); + } + + const img = new Image(); + img.src = imageUrl; + + img.onload = () => { + this.imageWidth = img.width; + this.imageHeight = img.height; + + const bounds = [[0, 0], [img.height, img.width]]; + + // Initialize Leaflet map with simple CRS for image + this.map = L.map(this.previewTarget, { + crs: L.CRS.Simple, + minZoom: -3, + maxZoom: 2, + center: [img.height / 2, img.width / 2], + zoom: -1, + attributionControl: false + }); + + // Add image overlay + this.imageOverlay = L.imageOverlay(imageUrl, bounds).addTo(this.map); + this.map.fitBounds(bounds); + + // Draw initial grid + this.redrawGrid(); + }; + + img.onerror = () => { + console.error('Failed to load image:', imageUrl); + // Show placeholder on error + if (this.hasPlaceholderTarget) { + this.placeholderTarget.classList.remove('d-none'); + } + if (this.hasPreviewTarget) { + this.previewTarget.classList.add('d-none'); + } + }; + } + + /** + * Redraw grid overlay with current settings + */ + redrawGrid() { + if (!this.map || !this.imageWidth || !this.imageHeight) { + return; + } + + // Remove existing grid layer + if (this.gridLayer) { + this.map.removeLayer(this.gridLayer); + this.gridLayer = null; + } + + // Don't draw if grid is not visible + if (!this.gridVisibleValue) { + return; + } + + const rows = this.gridRowsValue; + const cols = this.gridColumnsValue; + const opacity = this.gridOpacityValue; + const width = this.imageWidth; + const height = this.imageHeight; + + const cellWidth = width / cols; + const cellHeight = height / rows; + + this.gridLayer = L.layerGroup().addTo(this.map); + + // Draw horizontal lines + for (let i = 0; i <= rows; i++) { + const y = i * cellHeight; + L.polyline([[y, 0], [y, width]], { + color: '#000000', + weight: 1, + opacity: opacity + }).addTo(this.gridLayer); + } + + // Draw vertical lines + for (let i = 0; i <= cols; i++) { + const x = i * cellWidth; + L.polyline([[0, x], [height, x]], { + color: '#000000', + weight: 1, + opacity: opacity + }).addTo(this.gridLayer); + } + + // Add grid labels (only if grid is not too dense) + if (rows <= 26 && cols <= 26) { + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * cellWidth + cellWidth / 2; + const y = row * cellHeight + cellHeight / 2; + const label = this.getCellLabel(row, col); + + // Only show labels if cells are large enough + if (cellWidth > 30 && cellHeight > 30) { + L.marker([y, x], { + icon: L.divIcon({ + className: 'map-grid-label', + html: `{{ 'game_map.upload_to_preview'|trans({}, 'forms') }}
+{{ mapping.mappingConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
- {{ mapping.metaConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
+ {{ mapping.mappingConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
+ {{ mapping.metaConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
| Title | -Address | -City | -Capacity | -Status | -Approval | -Actions | -
|---|---|---|---|---|---|---|
| {{ 'location.title'|trans }} | +{{ 'location.address'|trans }} | +{{ 'location.city'|trans }} | +{{ 'location.capacity'|trans }} | +{{ 'location.status'|trans }} | +{{ 'location.approval'|trans }} | +{{ 'actions'|trans }} | +