mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 20:29:44 -05:00
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -238,6 +238,7 @@ class Config:
|
||||
"it": "Italiano",
|
||||
"fi": "Suomi",
|
||||
"es": "Español",
|
||||
"pt": "Português",
|
||||
"no": "Norsk",
|
||||
"ar": "العربية",
|
||||
"he": "עברית",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
@@ -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 Crowdin’s 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`.
|
||||
- **Crowdin’s 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
@@ -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
|
||||
|
||||
|
||||
@@ -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 Python’s `%` / `.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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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*`).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fill empty msgstr in a .po file using Argos Translate (offline en→pt, 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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+13063
-15517
File diff suppressed because it is too large
Load Diff
+13063
-15517
File diff suppressed because it is too large
Load Diff
+9623
-12643
File diff suppressed because it is too large
Load Diff
+13063
-15517
File diff suppressed because it is too large
Load Diff
+13040
-15494
File diff suppressed because it is too large
Load Diff
+13063
-15517
File diff suppressed because it is too large
Load Diff
+13062
-15516
File diff suppressed because it is too large
Load Diff
+13062
-15516
File diff suppressed because it is too large
Load Diff
+17128
-17643
File diff suppressed because it is too large
Load Diff
+13064
-15518
File diff suppressed because it is too large
Load Diff
+13062
-15516
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user