Merge pull request #599 from DRYTRIX/rc/v5.5.1

Rc/v5.5.1
This commit is contained in:
Dries Peeters
2026-04-29 13:25:14 +02:00
committed by GitHub
39 changed files with 172810 additions and 169950 deletions
+2
View File
@@ -130,6 +130,8 @@ dmypy.json
.pyre/
# Application specific
# gettext template produced by `pybabel extract` (regenerate as needed; do not commit)
messages.pot
data/
# Flutter app source lives under mobile/lib/data/ (do not treat as runtime data dir)
!mobile/lib/data/
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Quote edit redirect for delegated editors** — Users with `edit_quotes` permission could save changes on draft quotes they did not create but were redirected to an empty/“not found” flow because quote detail/list visibility was still filtered by `created_by`. Quote list/detail scope now matches edit capability for users with `edit_quotes` across web and API quote reads. Added a regression test for edit-then-redirect view loading and updated quote comment edit context links.
## [5.5.0] - 2026-04-27
### Added
+4 -4
View File
@@ -89,7 +89,7 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh
> **📋 For complete release history, see [CHANGELOG.md](CHANGELOG.md)**
**Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history.
- 📱 **Native Mobile & Desktop Apps** — Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](docs/build/BUILD.md), [Docs](docs/mobile-desktop-apps/README.md))
- 📱 **Native Mobile & Desktop Apps** — Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](scripts/README-BUILD.md), [Docs](docs/mobile-desktop-apps/README.md))
- 📋 **Project Analysis & Documentation** — Comprehensive project analysis and documentation updates
- 🔧 **Version Consistency** — Fixed version inconsistencies across documentation files
@@ -290,7 +290,7 @@ TimeTracker includes **130+ features** across 13 major categories. See the [Comp
- **Docker Ready** — Deploy in minutes with Docker Compose
- **Database Flexibility** — PostgreSQL for production, SQLite for testing
- **Responsive Design** — Mobile-first design works perfectly on desktop, tablet, and mobile
- **Native Mobile & Desktop Apps** — Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](docs/build/BUILD.md), [Docs](docs/mobile-desktop-apps/README.md))
- **Native Mobile & Desktop Apps** — Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](scripts/README-BUILD.md), [Docs](docs/mobile-desktop-apps/README.md))
- **Real-time Sync** — WebSocket support for live updates across devices
- **Automatic Backups** — Scheduled database backups (configurable)
- **Progressive Web App (PWA)** — Install as mobile app with offline support and background sync
@@ -631,7 +631,7 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory. See
**Integrations & Apps:**
- **[Mobile & Desktop Apps](docs/mobile-desktop-apps/README.md)** — Flutter mobile and Electron desktop apps
- **[Build Guide (Mobile & Desktop)](docs/build/BUILD.md)** — Build scripts for Android, iOS, Windows, macOS, Linux
- **[Build Guide (Mobile & Desktop)](scripts/README-BUILD.md)** — Build scripts for Android, iOS, Windows, macOS, Linux
- **[Peppol & ZugFerd e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md)** — Peppol sending and ZugFerd/Factur-X PDF embedding (EN 16931)
- **[API Documentation](docs/api/REST_API.md)** — REST API reference
- **[API Token Scopes](docs/api/API_TOKEN_SCOPES.md)** — Token permissions
@@ -963,7 +963,7 @@ This starts:
#### 📱 Native Mobile & Desktop Apps
-**Flutter Mobile App** — Native iOS and Android apps with time tracking, calendar view, offline sync, and API token authentication
-**Electron Desktop App** — Windows, macOS, and Linux desktop app with system tray, time tracking, and offline support
-**Build Scripts** — Cross-platform build scripts for mobile and desktop ([BUILD.md](docs/build/BUILD.md))
-**Build Scripts** — Cross-platform build scripts for mobile and desktop ([Build Guide](scripts/README-BUILD.md))
#### 🏗️ Architecture & Performance
-**Service Layer Migration** — Routes migrated to service layer pattern
+9 -7
View File
@@ -415,18 +415,20 @@ def create_app(config=None):
"""Normalize locale codes for Flask-Babel compatibility.
Some locale codes need to be normalized:
- 'no' -> 'nb' (Norwegian Bokmål is the standard, but we'll try 'no' first)
- 'no' -> 'nb' (Norwegian Bokmål catalog directory)
- 'pt-br', 'pt_pt', 'pt-pt', etc. -> 'pt' (single Portuguese catalog)
"""
if not locale_code:
return "en"
locale_code = locale_code.lower().strip()
# Try 'no' first - if translations don't exist, Flask-Babel will fall back
# If 'no' doesn't work, we can map to 'nb' as fallback
# For now, keep 'no' as-is since we have translations/nb/ directory
# The directory structure should match what Flask-Babel expects
raw = locale_code.strip()
lower = raw.lower().replace("_", "-")
# Portuguese: fold regional variants to generic pt (one catalog under translations/pt/)
if lower == "pt" or lower.startswith("pt-"):
# pt-br, pt-pt, PT -> pt
return "pt"
locale_code = raw.lower().strip()
if locale_code == "no":
# Use 'nb' for Flask-Babel (standard Norwegian Bokmål locale)
# But ensure we have translations in both 'no' and 'nb' directories
return "nb"
return locale_code
+1
View File
@@ -238,6 +238,7 @@ class Config:
"it": "Italiano",
"fi": "Suomi",
"es": "Español",
"pt": "Português",
"no": "Norsk",
"ar": "العربية",
"he": "עברית",
+8 -3
View File
@@ -68,6 +68,7 @@ from app.utils.api_responses import (
validation_error_response,
)
from app.utils.error_handling import safe_log
from app.utils.quote_access import quote_list_scope_user_id
from app.utils.scope_filter import apply_client_scope, apply_project_scope
from app.utils.timezone import get_app_timezone, parse_local_datetime, utc_to_local
@@ -1307,7 +1308,7 @@ def list_quotes():
# Use service layer with eager loading
quote_service = QuoteService()
result = quote_service.list_quotes(
user_id=g.api_user.id if not g.api_user.is_admin else None,
user_id=quote_list_scope_user_id(g.api_user),
is_admin=g.api_user.is_admin,
status=status,
search=None,
@@ -1352,7 +1353,9 @@ def get_quote(quote_id):
quote_service = QuoteService()
quote = quote_service.get_quote_with_details(
quote_id=quote_id, user_id=g.api_user.id if not g.api_user.is_admin else None, is_admin=g.api_user.is_admin
quote_id=quote_id,
user_id=quote_list_scope_user_id(g.api_user),
is_admin=g.api_user.is_admin,
)
if not quote:
@@ -1553,7 +1556,9 @@ def delete_quote(quote_id):
# Use service layer with eager loading
quote_service = QuoteService()
quote = quote_service.get_quote_with_details(
quote_id=quote_id, user_id=g.api_user.id if not g.api_user.is_admin else None, is_admin=g.api_user.is_admin
quote_id=quote_id,
user_id=quote_list_scope_user_id(g.api_user),
is_admin=g.api_user.is_admin,
)
if not quote:
+3 -2
View File
@@ -10,6 +10,7 @@ from app.models import Client, Invoice, Project, Quote, QuoteAttachment, QuoteIm
from app.utils.config_manager import ConfigManager
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required, permission_required
from app.utils.quote_access import quote_list_scope_user_id
quotes_bp = Blueprint("quotes", __name__)
@@ -71,7 +72,7 @@ def list_quotes():
quote_service = QuoteService()
result = quote_service.list_quotes(
user_id=current_user.id if not current_user.is_admin else None,
user_id=quote_list_scope_user_id(current_user),
is_admin=current_user.is_admin,
status=status,
search=search if search else None,
@@ -486,7 +487,7 @@ def view_quote(quote_id):
quote_service = QuoteService()
quote = quote_service.get_quote_with_details(
quote_id=quote_id,
user_id=current_user.id if not current_user.is_admin else None,
user_id=quote_list_scope_user_id(current_user),
is_admin=current_user.is_admin,
)
+4
View File
@@ -32,6 +32,10 @@
<a href="{{ url_for('tasks.view_task', task_id=comment.task.id) }}" class="ms-2">
<i class="fas fa-tasks me-1"></i>{{ comment.task.name }}
</a>
{% elif comment.quote %}
<a href="{{ url_for('quotes.view_quote', quote_id=comment.quote.id) }}" class="ms-2">
<i class="fas fa-file-contract me-1"></i>{{ comment.quote.quote_number }} — {{ comment.quote.title }}
</a>
{% endif %}
</div>
</div>
+19
View File
@@ -0,0 +1,19 @@
"""Quote visibility helpers — align list/detail scope with edit capability."""
from __future__ import annotations
from typing import Any, Optional
def quote_list_scope_user_id(user: Any) -> Optional[int]:
"""Return user id to scope Quote queries, or None if the user may see all quotes.
Non-admins normally see only quotes they created. Users with ``edit_quotes`` may
edit any draft quote via URL; they must also see those quotes on list/detail views.
"""
if getattr(user, "is_admin", False):
return None
hp = getattr(user, "has_permission", None)
if callable(hp) and hp("edit_quotes"):
return None
return getattr(user, "id", None)
+4
View File
@@ -1,3 +1,7 @@
# Map short name to import path (needed when setuptools entry points are not visible to Babel).
[extractors]
jinja2 = jinja2.ext:babel_extract
[python: app/**.py]
[python: *.py]
[jinja2: app/templates/**.html]
+12
View File
@@ -221,6 +221,14 @@ if current_user.has_all_permissions('create_invoices', 'send_invoices'):
# Allow action
```
#### Quote access scope note
For quote listing/detail routes, users with quote-management permissions (for example `edit_quotes`) may need access beyond "own quotes only" in order to open the quote they just edited from redirects and list views. Keep list/detail scoping aligned with route-level permission intent to avoid "edit succeeds but view returns 404/redirect" behavior.
Current implementation uses a shared quote-access helper so quote list/detail scope matches edit capability: admins and users with `edit_quotes` can access quote list/detail across owners, while users without that permission remain scoped to their own quotes.
Validation reference: behavior is covered by quote web/API regression tests in `tests/test_routes/test_quotes_web.py` and `tests/test_routes/test_api_v1_quotes_refactored.py`.
#### Using Permission Decorators
Protect routes with permission decorators:
@@ -357,6 +365,8 @@ This command updates permissions and roles without affecting user assignments.
GET /api/users/<user_id>/permissions
```
Access expectations: intended for administrator use (admin session / admin-equivalent role).
Returns:
```json
{
@@ -377,6 +387,8 @@ Returns:
GET /api/roles/<role_id>/permissions
```
Access expectations: intended for administrator use (admin session / admin-equivalent role).
Returns:
```json
{
+2 -2
View File
@@ -31,7 +31,7 @@ All build scripts automatically sync the version before building.
**Version Code Calculation:**
- Android version code is calculated as: `major * 10000 + minor * 100 + patch`
- Example: Version `5.5.0` → version code `50500`
- Example: Version `5.5.1` → version code `50501`
**Build Scripts:**
- `scripts/build-mobile.bat` (Windows)
@@ -184,7 +184,7 @@ To update the version for all applications:
```python
setup(
name='timetracker',
version='5.5.0', # Update here
version='5.5.1', # Update here
...
)
```
+6 -2
View File
@@ -40,7 +40,7 @@ This repo includes a root [`crowdin.yml`](../crowdin.yml) that maps **source** `
1. **Crowdin account and project** — [Sign up at Crowdin](https://crowdin.com/) if needed. Translators work in **[Drytrix TimeTracker](https://crowdin.com/project/drytrix-timetracker)** (ask a maintainer for access if the project is private). Maintainers configure API tokens and GitHub integration against that same project unless you intentionally use a separate test project.
2. **Source language:** English. Treat the resource as **Gettext PO** (`.po`).
3. **Target languages:** Add every locale you ship: `nl`, `de`, `fr`, `it`, `fi`, `es`, `no`, `ar`, `he` (match `LANGUAGES` in `app/config.py`). For Norwegian, add Norwegian (Bokmål) in Crowdin; the `crowdin.yml` mapping writes files into `translations/no/`.
3. **Target languages:** Add every locale you ship: `nl`, `de`, `fr`, `it`, `fi`, `es`, `pt`, `no`, `ar`, `he` (match `LANGUAGES` in `app/config.py`). For Norwegian, add Norwegian (Bokmål) in Crowdin; the `crowdin.yml` mapping writes files into `translations/no/`.
4. **Sync with this repository (pick one):**
- **GitHub Action:** In the GitHub repo, add Actions secrets `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` (Crowdin project **Details** shows the numeric project ID; **Account Settings → API** creates the token with project access, typically Manager). Run **Crowdin sync** from the **Actions** tab → **Run workflow**. For a **one-time** import of existing `.po` files into Crowdins translation memory, temporarily set `upload_translations: true` in [.github/workflows/crowdin-sync.yml](../.github/workflows/crowdin-sync.yml), run it once, then set it back to `false`.
- **Crowdins GitHub integration:** Crowdin → **Integrations → GitHub** → connect the repo and branch; point it at the same `crowdin.yml` so Crowdin can open PRs when translations are updated.
@@ -79,12 +79,16 @@ Follow these so your suggestion can be applied without breaking the app:
4. **Context matters.** Say which **page**, **button**, or **dialog** the text appears on, and attach a **screenshot** if possible. One English phrase can appear in multiple places with different meanings.
5. **Length and tone:** Short labels (buttons, nav) should stay compact. Full sentences can be more natural in your language than literal word-for-word English.
**Supported locale codes** (see `app/config.py` `LANGUAGES`): `en`, `nl`, `de`, `fr`, `it`, `fi`, `es`, `no`, `ar`, `he`.
**Supported locale codes** (see `app/config.py` `LANGUAGES`): `en`, `nl`, `de`, `fr`, `it`, `fi`, `es`, `pt`, `no`, `ar`, `he`.
## Maintainer workflow
Designate at least one person responsible for translation intake (issues, spreadsheet, or platform export).
### Syncing catalogs with the codebase
When new or changed `msgid` strings land in the app, refresh every locale from a new template: run **`pybabel extract`** then **`pybabel update`** as in [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md) (venv with Babel + Jinja2, `babel.cfg` **`[extractors]`** block for Jinja2, root **`messages.pot`** gitignored). Use **`--ignore-obsolete`** if you want obsolete entries removed from all `.po` files after a large refactor.
### Applying contributor suggestions
1. Identify the locale file: `translations/<locale>/LC_MESSAGES/messages.po`.
+1 -1
View File
@@ -119,7 +119,7 @@ See [Contributing Testing](docs/development/CONTRIBUTING.md#testing) for mor
- **Web app:** No separate frontend build required; Tailwind and static assets are served as-is (or built via your pipeline if you use one). Run the app with `flask run` or `python app.py`.
- **Docker image:** `docker build -t timetracker .` from repo root. See [Docker Compose Setup](docs/admin/configuration/DOCKER_COMPOSE_SETUP.md).
- **Mobile/Desktop:** See [BUILD.md](build/BUILD.md) and [mobile-desktop-apps/README.md](mobile-desktop-apps/README.md) for Flutter and Electron build steps.
- **Mobile/Desktop:** See [Build Guide](../scripts/README-BUILD.md) and [mobile-desktop-apps/README.md](mobile-desktop-apps/README.md) for Flutter and Electron build steps.
## Contributing
+33 -7
View File
@@ -10,8 +10,11 @@ TimeTracker includes a comprehensive internationalization (i18n) system powered
- **French** (fr - Français)
- **Italian** (it - Italiano)
- **Finnish** (fi - Suomi)
- **Portuguese** (pt - Português)
- **Spanish** (es), **Norwegian** (no), **Arabic** (ar), **Hebrew** (he), and others as configured
Regional Portuguese tags (`pt-BR`, `pt-PT`, etc.) are normalized to **`pt`** so a single catalog under `translations/pt/` is used.
## User Experience
### Language Switcher
@@ -50,6 +53,7 @@ translations/
├── it/LC_MESSAGES/messages.po # Italian
├── fi/LC_MESSAGES/messages.po # Finnish
├── es/LC_MESSAGES/messages.po # Spanish
├── pt/LC_MESSAGES/messages.po # Portuguese
└── ... # Other locales as configured
```
@@ -65,6 +69,8 @@ LANGUAGES = {
'fr': 'Français',
'it': 'Italiano',
'fi': 'Suomi',
'pt': 'Português',
# ... es, no, ar, he, etc. — see app/config.py for the full map
}
BABEL_DEFAULT_LOCALE = 'en'
```
@@ -78,6 +84,8 @@ The system determines the user's language in the following order:
3. **Browser Accept-Language header** (best match)
4. **Default locale** (en)
The locale normalizer maps **`no``nb`** (Norwegian catalog folder) and folds **`pt-*`** (e.g. `pt-BR`, `pt-PT`) to **`pt`**.
See `app/__init__.py` for the locale selector implementation.
### In Templates
@@ -144,19 +152,37 @@ To add a new language:
When you add new translatable strings to the application:
1. **Extract messages**:
1. Use a virtualenv with project dependencies (at least **Babel** and **Jinja2**) so `pybabel` can scan templates.
2. **Extract messages** (writes `messages.pot` at the repo root; it is gitignored):
```bash
pybabel extract -F babel.cfg -o messages.pot .
```
2. **Update all translation files**:
`babel.cfg` defines a `[extractors]` alias so the **jinja2** method resolves to `jinja2.ext:babel_extract` (needed on some Python/setuptools setups where the `babel.extractors` entry point is not visible).
3. **Update all translation files**:
```bash
pybabel update -i messages.pot -d translations
pybabel update -i messages.pot -d translations --no-wrap
```
3. **Translate new strings** in each `.po` file
To drop obsolete entries after a large refactor:
```bash
pybabel update -i messages.pot -d translations --ignore-obsolete --no-wrap
```
4. **Restart application** - changes will be compiled automatically
4. **Translate new strings** in each `.po` file (human review, [Crowdin](https://crowdin.com/project/drytrix-timetracker), or a local helper).
For a **first-pass Portuguese fill** using offline Argos models (machine quality; always review):
```bash
pip install polib argostranslate
python -c "import argostranslate.package as p; p.update_package_index(); pkg=next(x for x in p.get_available_packages() if x.from_code=='en' and x.to_code=='pt'); p.install_from_path(pkg.download())"
python scripts/fill_po_argos.py translations/pt/LC_MESSAGES/messages.po --from en --to pt
```
Machine translators often break `%(name)s` / `{name}` placeholders. Run **`python scripts/sanitize_po_format_strings.py translations/pt/LC_MESSAGES/messages.po`** afterward, then **`msgfmt --check-format -o /dev/null …/messages.po`** to confirm the catalog is safe for Pythons `%` / `.format()` at runtime.
5. **Restart application** (or set `TT_COMPILE_TRANSLATIONS_ON_STARTUP=true`) so `.mo` files are compiled when needed
## Translation File Format
@@ -253,7 +279,7 @@ The language switcher includes:
Potential improvements:
1. Add more languages (Spanish, Portuguese, Japanese, etc.)
1. Add more languages (Japanese, Chinese, etc.)
2. Right-to-left (RTL) language support (Arabic, Hebrew)
3. User-contributed translations via [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issues, spreadsheet, [Crowdin — Drytrix TimeTracker](https://crowdin.com/project/drytrix-timetracker), or Weblate)
4. Automatic language detection improvement
@@ -271,7 +297,7 @@ For questions or issues with translations:
---
**Last Updated**: 2026-04-15
**Last Updated**: 2026-04-29 (catalog sync and `babel.cfg` extractors note)
**Flask-Babel Version**: 4.0.0
**Babel Version**: 2.14.0
+1 -1
View File
@@ -2,7 +2,7 @@
This document describes the comprehensive version management system for TimeTracker that provides flexible versioning for both GitHub releases and build numbers.
**For contributors:** Application version is defined only in **setup.py**. Do not duplicate it in README or other docs. Desktop and mobile builds may use their own version numbers; see [BUILD.md](../../build/BUILD.md) and repo scripts.
**For contributors:** Application version is defined only in **setup.py**. Do not duplicate it in README or other docs. Desktop and mobile builds may use their own version numbers; see the [Build Guide](../../../scripts/README-BUILD.md) and repo scripts.
**OpenAPI (`/api/openapi.json`):** The `info.version` field uses the same resolution as the in-app version helpers: environment variables **`TIMETRACKER_VERSION`** or **`APP_VERSION`** override the value read from **`setup.py`**; see `get_version_from_setup()` in `app/config/analytics_defaults.py` and `openapi_spec()` in `app/routes/api_docs.py`.
+31
View File
@@ -206,6 +206,33 @@ curl -X POST https://your-domain.com/api/v1/clients \
---
### Quotes
#### `read:quotes`
**Grants**: List and view quotes
**Endpoints**:
- `GET /api/v1/quotes` - List quotes
- `GET /api/v1/quotes/{id}` - Get quote details
**Use Cases**:
- Client portal and CRM read integrations
- Quote status dashboards
- External systems that only need quote visibility
#### `write:quotes`
**Grants**: Create, update, and delete quotes
**Endpoints**:
- `POST /api/v1/quotes` - Create quote
- `PUT /api/v1/quotes/{id}` - Update quote
- `DELETE /api/v1/quotes/{id}` - Delete quote
**Use Cases**:
- Quote generation from external systems
- Automated quote updates and status sync
- Back-office quote lifecycle tools
---
### Invoices
#### `read:invoices`
@@ -368,6 +395,8 @@ read:tasks
write:tasks
read:clients
write:clients
read:quotes
write:quotes
read:reports
```
**Use For**: Personal automation, full-featured integrations
@@ -565,6 +594,8 @@ curl -X POST https://your-domain.com/api/v1/projects \
| `write:tasks` | ✅ | ✅ | ❌ | Manage tasks |
| `read:clients` | ✅ | ❌ | ❌ | View clients |
| `write:clients` | ✅ | ✅ | ❌ | Manage clients |
| `read:quotes` | ✅ | ❌ | ❌ | View quotes |
| `write:quotes` | ✅ | ✅ | ❌ | Manage quotes |
| `read:reports` | ✅ | ❌ | ❌ | View own reports |
| `read:users` | ✅ | ❌ | Partial | `/users/me` for all, `/users` admin only |
| `admin:all` | ✅ | ✅ | ✅ | Full access |
+50
View File
@@ -86,6 +86,8 @@ API tokens use scopes to control access to resources. When creating a token, sel
| `write:tasks` | Create and update tasks |
| `read:clients` | View clients |
| `write:clients` | Create and update clients |
| `read:quotes` | View quotes |
| `write:quotes` | Create and update quotes |
| `read:reports` | View reports and analytics |
| `read:users` | View user information |
| `admin:all` | Full administrative access (use with caution) |
@@ -695,6 +697,54 @@ POST /api/v1/clients
}
```
### Quotes
#### List Quotes
```
GET /api/v1/quotes
```
**Required Scope:** `read:quotes`
#### Get Quote
```
GET /api/v1/quotes/{quote_id}
```
**Required Scope:** `read:quotes`
#### Create Quote
```
POST /api/v1/quotes
```
**Required Scope:** `write:quotes`
**Request Body (example):**
```json
{
"client_id": 1,
"title": "Website maintenance retainer",
"description": "Monthly maintenance and support",
"tax_rate": 21.0,
"currency_code": "EUR"
}
```
#### Update Quote
```
PUT /api/v1/quotes/{quote_id}
```
**Required Scope:** `write:quotes`
#### Delete Quote
```
DELETE /api/v1/quotes/{quote_id}
```
**Required Scope:** `write:quotes`
### Inventory
Inventory endpoints require the **inventory module** to be enabled (Admin settings). They use `read:projects` and `write:projects` scopes.
+1 -1
View File
@@ -87,7 +87,7 @@ Run the full test suite before opening a PR. Add tests for new behavior (e.g. in
## Versioning
- **Application version**: Defined **only** in `setup.py`. Do not duplicate it in README or other docs.
- **Desktop / mobile**: Desktop and mobile builds may use their own version numbers; see [BUILD.md](../build/BUILD.md) and repo scripts. Align with app version when releasing together.
- **Desktop / mobile**: Desktop and mobile builds may use their own version numbers; see [Build Guide](../../scripts/README-BUILD.md) and repo scripts. Align with app version when releasing together.
- **Releases and Docker images**: Tagging, GitHub releases, and image publishing are in [VERSION_MANAGEMENT.md](../admin/deployment/VERSION_MANAGEMENT.md) and [RELEASE_PROCESS.md](../admin/deployment/RELEASE_PROCESS.md).
**For contributors**: When updating the app version, change only `setup.py`. Do not add the version number to README, FEATURES_COMPLETE, or PROJECT_STRUCTURE.
+21
View File
@@ -34,12 +34,33 @@ These blueprints use only `@login_required`. Any logged-in user can access the r
**Examples:** deals, leads, invoices (main routes), timer, reports, calendar, expenses (main routes), main dashboard, time_approvals, contacts, tasks, client_notes, budget_alerts, payments, recurring_invoices, etc.
### Quotes access nuance
Quotes use permission checks plus scope logic aligned to effective capabilities. In practice:
- users with `edit_quotes` are allowed quote list/detail visibility beyond own-created quotes so post-edit redirects and detail pages remain accessible;
- users without quote-management permissions remain scoped to their own quotes;
- admins retain full access.
This behavior is implemented via shared quote access helpers (for list/detail scope parity) and is regression-tested in `tests/test_routes/test_quotes_web.py`.
## When to add permission decorators
- **New admin-only or sensitive feature:** Use `@admin_or_permission_required("appropriate_permission")` and define the permission in the permission system if it does not exist.
- **New feature for all users:** Use only `@login_required`.
- **Existing “login only” route:** Leave as-is unless you are explicitly tightening access; then add a permission and document it in ADVANCED_PERMISSIONS.md.
## Denial behavior in web routes
For UI routes protected by permission decorators, unauthorized non-admin users can be denied in two valid ways depending on route and UX flow:
- direct `403 Forbidden` response, or
- redirect to a page that returns `200` and shows an access/error message (for example when `follow_redirects=True` in tests).
Keep tests and docs tolerant of both outcomes where the user is denied access but not shown privileged content (see `tests/test_permissions_routes.py`).
## API v1 (REST)
REST API v1 uses API token scopes (e.g. `read:deals`, `write:time_entries`) rather than web permission names. See [API Token Scopes](../api/API_TOKEN_SCOPES.md) and [REST_API.md](../api/REST_API.md).
Quotes in API v1 require `read:quotes` for list/detail and `write:quotes` for create/update/delete (`/api/v1/quotes*`).
+3 -3
View File
@@ -256,9 +256,9 @@ print(f"Jobs: {scheduler.get_jobs()}")
| Feature | Model | Routes | Template |
|---------|-------|--------|----------|
| Time Entry Templates | `app/models/time_entry_template.py` | TBD | TBD |
| Activity Feed | `app/models/activity.py` | TBD | TBD |
| User Preferences | `app/models/user.py` | TBD | TBD |
| Time Entry Templates | `app/models/time_entry_template.py` | `app/routes/api_v1.py` (`/api/v1/time-entry-templates`) | API-driven (consumed by clients) |
| Activity Feed | `app/models/activity.py` | `app/routes/main.py` (dashboard feed), `app/routes/projects.py` (project activity) | `app/templates/dashboard.html`, `app/templates/projects/view.html` |
| User Preferences | `app/models/user.py` | `app/routes/user.py` (`/settings`, `/api/preferences`) | `app/templates/user/settings.html` |
| Excel Export | `app/utils/excel_export.py` | `app/routes/reports.py` | Add button |
| Email Notifications | `app/utils/email.py` | Automatic | `app/templates/email/` |
| Scheduled Tasks | `app/utils/scheduled_tasks.py` | Automatic | N/A |
@@ -203,7 +203,7 @@ To test the translation system:
Potential improvements for the future:
1. Add more languages (Spanish, Portuguese, Japanese, Chinese)
1. Add more languages (Japanese, Chinese, etc.)
2. Implement RTL support for Arabic and Hebrew
3. Add translation management UI in admin panel
4. Integrate with translation services (Crowdin, Lokalise)
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
Fill empty msgstr in a .po file using Argos Translate (offline enpt, etc.).
Usage (from repo root, with venv activated and Argos model installed):
python scripts/fill_po_argos.py translations/pt/LC_MESSAGES/messages.po --from en --to pt
python scripts/sanitize_po_format_strings.py translations/pt/LC_MESSAGES/messages.po
msgfmt --check-format -o /dev/null translations/pt/LC_MESSAGES/messages.po
Requires: pip install polib argostranslate
Install Argos language pair once, e.g.:
python -c "import argostranslate.package as p; p.update_package_index(); ..."
"""
from __future__ import annotations
import argparse
import sys
import argostranslate.translate
import polib
def translate_text(text: str, from_code: str, to_code: str) -> str:
if not text or not text.strip():
return text
try:
return argostranslate.translate.translate(text, from_code, to_code)
except Exception:
return text
def main() -> int:
parser = argparse.ArgumentParser(description="Fill empty PO msgstr using Argos Translate")
parser.add_argument("po_path", help="Path to messages.po to update")
parser.add_argument("--from", dest="from_code", default="en", help="Source language code (default: en)")
parser.add_argument("--to", dest="to_code", default="pt", help="Target language code (default: pt)")
parser.add_argument("--limit", type=int, default=0, help="Max messages to translate (0 = all)")
args = parser.parse_args()
po = polib.pofile(args.po_path)
done = 0
limit = args.limit or None
for entry in po:
if entry.obsolete or not entry.msgid:
continue
if entry.translated() and entry.msgstr.strip():
continue
if entry.msgid_plural:
# Portuguese: two plural forms typical in our header
t0 = translate_text(entry.msgid, args.from_code, args.to_code)
t1 = translate_text(entry.msgid_plural, args.from_code, args.to_code)
entry.msgstr_plural = {0: t0, 1: t1}
else:
entry.msgstr = translate_text(entry.msgid, args.from_code, args.to_code)
entry.flags = [f for f in entry.flags if f != "fuzzy"]
done += 1
if done % 500 == 0:
print(f"translated {done}...", flush=True)
if limit is not None and done >= limit:
break
po.metadata["Last-Translator"] = "Argos Translate (machine) + scripts/fill_po_argos.py"
po.save(args.po_path)
print(f"Done. Updated {done} entries in {args.po_path}")
return 0
if __name__ == "__main__":
sys.exit(main())
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
Clear msgstr / msgstr_plural when they break Python formatting vs msgid.
Machine translations often corrupt %(name)s (spaces, wrong names) or
brace placeholders {name}, which causes gettext to raise at runtime
(e.g. ValueError: unsupported format character '(').
Usage (repo root, venv with polib):
python scripts/sanitize_po_format_strings.py translations/pt/LC_MESSAGES/messages.po
"""
from __future__ import annotations
import argparse
import re
import sys
from string import Formatter
import polib
def _percent_keys(msgid: str) -> list[str]:
return re.findall(r"%\(([^)]+)\)", msgid)
def _safe_percent(msgid: str, msgstr: str) -> bool:
if not msgstr:
return True
keys_m = _percent_keys(msgid)
keys_s = _percent_keys(msgstr)
if keys_m:
# Machine translation often turns %(name)s into %s — dict apply then "succeeds" wrongly.
if set(keys_m) != set(keys_s):
return False
d = {k: "__x__" for k in keys_m}
try:
msgstr % d
return True
except (ValueError, KeyError, TypeError):
return False
if "%" not in msgid.replace("%%", ""):
return True
if "%(" in msgid:
return True
# Positional %s / %d / %(no) — approximate count of conversion specs
pat = re.compile(
r"(?<!%)(?:%%)*(?:%(?!%))(?!\()"
r"(?:\d+\$)?[-#+0 ]*(?:\*|\d+)?(?:\.(?:\*|\d+))?[hlL]?[diouxXeEfFgGcrsa%]"
)
nm, ns = len(pat.findall(msgid)), len(pat.findall(msgstr))
if nm == 0:
return True
if nm != ns:
return False
try:
msgstr % tuple(["0"] * nm)
return True
except (ValueError, TypeError):
return False
def _brace_field_names(msgid: str) -> set[str]:
out: set[str] = set()
try:
for _, field_name, _, _ in Formatter().parse(msgid):
if field_name is not None:
parts = field_name.split(".")
out.add(parts[0])
except ValueError:
return set()
return out
def _safe_brace(msgid: str, msgstr: str) -> bool:
if not msgstr:
return True
if "{" not in msgid:
return True
names = _brace_field_names(msgid)
if not names:
return True
kw = {n: "__x__" for n in names}
try:
msgstr.format(**kw)
return True
except (ValueError, KeyError, IndexError):
return False
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("po_path")
args = ap.parse_args()
po = polib.pofile(args.po_path)
cleared = 0
for entry in po:
if entry.obsolete or not entry.msgid:
continue
if entry.msgid_plural:
bad = False
if entry.msgstr_plural:
for idx, s in entry.msgstr_plural.items():
if not s:
continue
i = int(idx) if str(idx).isdigit() else 0
mid = entry.msgid if i == 0 else entry.msgid_plural
if ("python-brace-format" in entry.flags) or ("{" in mid and "{" in s):
if not _safe_brace(mid, s):
bad = True
break
if ("%(" in mid) or ("python-format" in entry.flags):
if not _safe_percent(mid, s):
bad = True
break
if bad:
entry.msgstr_plural = {}
entry.flags = [f for f in entry.flags if f != "fuzzy"]
cleared += 1
continue
msgstr = entry.msgstr or ""
if not msgstr:
continue
bad = False
if ("python-brace-format" in entry.flags) or ("{" in entry.msgid and "{" in msgstr):
if not _safe_brace(entry.msgid, msgstr):
bad = True
if not bad and ("%(" in entry.msgid or "python-format" in entry.flags):
if not _safe_percent(entry.msgid, msgstr):
bad = True
if bad:
entry.msgstr = ""
entry.flags = [f for f in entry.flags if f != "fuzzy"]
cleared += 1
po.metadata["Last-Translator"] = "Sanitized invalid format strings (scripts/sanitize_po_format_strings.py)"
po.save(args.po_path)
print(f"Cleared {cleared} broken format translation(s) in {args.po_path}")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='5.5.0',
version='5.5.1',
packages=find_packages(),
include_package_data=True,
package_data={
+3 -1
View File
@@ -28,10 +28,12 @@ class TestI18nConfiguration:
assert "nl" in languages
assert "it" in languages
assert "fi" in languages
assert "pt" in languages
# Check that language labels are set
assert languages["en"] == "English"
assert languages["es"] == "Español"
assert languages["pt"] == "Português"
assert languages["ar"] == "العربية"
assert languages["he"] == "עברית"
@@ -248,7 +250,7 @@ class TestTranslations:
"""Test that translation files exist for all languages"""
import os
languages = ["en", "de", "fr", "es", "ar", "he", "nl", "it", "fi"]
languages = ["en", "de", "fr", "es", "ar", "he", "nl", "it", "fi", "pt"]
for lang in languages:
po_file = os.path.join("translations", lang, "LC_MESSAGES", "messages.po")
+65
View File
@@ -2,6 +2,9 @@
from flask import url_for
from app import db
from app.models import Permission, Quote, Role, User
def test_create_quote_redirect_then_view_returns_200(admin_authenticated_client, test_client, app):
with app.app_context():
@@ -25,3 +28,65 @@ def test_create_quote_redirect_then_view_returns_200(admin_authenticated_client,
view_resp = admin_authenticated_client.get(location, follow_redirects=False)
assert view_resp.status_code == 200, view_resp.data[:500]
def test_edit_quote_by_user_with_edit_quotes_redirect_then_view_returns_200(app, client, admin_user, test_client):
"""Non-admin with edit_quotes must see another user's quote after POST edit (list/detail scope matches edit)."""
with app.app_context():
perm = Permission.query.filter_by(name="edit_quotes").first()
if not perm:
perm = Permission(name="edit_quotes", description="Edit quotes", category="quotes")
db.session.add(perm)
db.session.commit()
role = Role.query.filter_by(name="test_quote_editor").first()
if not role:
role = Role(name="test_quote_editor", description="Quote editor test role")
db.session.add(role)
db.session.flush()
if perm not in role.permissions:
role.permissions.append(perm)
editor = User.query.filter_by(username="quote_editor_test").first()
if not editor:
editor = User(username="quote_editor_test", email="quote_editor_test@example.com", role="user")
editor.is_active = True
editor.set_password("password123")
db.session.add(editor)
db.session.flush()
if role not in editor.roles:
editor.roles.append(role)
db.session.commit()
quote = Quote(
quote_number=Quote.generate_quote_number(),
client_id=test_client.id,
title="Quote by admin",
created_by=admin_user.id,
)
db.session.add(quote)
db.session.commit()
quote_id = quote.id
editor_id = editor.id
edit_url = url_for("quotes.edit_quote", quote_id=quote_id)
with client.session_transaction() as sess:
sess["_user_id"] = str(editor_id)
resp = client.post(
edit_url,
data={
"title": "Updated by editor",
"tax_rate": "0",
"currency_code": "EUR",
},
follow_redirects=False,
)
assert resp.status_code in (302, 303), resp.data[:500]
location = resp.headers.get("Location", "")
assert f"/quotes/{quote_id}" in location
view_resp = client.get(location, follow_redirects=False)
assert view_resp.status_code == 200, view_resp.data[:500]
assert b"Updated by editor" in view_resp.data
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff