├── .gitignore ├── .github └── workflows │ ├── ci.yml │ ├── flakehub-publish-rolling.yml │ └── publish.yaml ├── longer-celery-wait-time.patch ├── cache.lock.patch ├── README.md ├── integration-test.nix ├── flake.lock ├── pyproject.toml ├── flake.nix └── module.nix /.gitignore: -------------------------------------------------------------------------------- 1 | result* 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | packages: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: cachix/install-nix-action@v21 12 | with: 13 | github_access_token: ${{ secrets.GITHUB_TOKEN }} 14 | - run: nix build -L --no-link .#weblate 15 | -------------------------------------------------------------------------------- /.github/workflows/flakehub-publish-rolling.yml: -------------------------------------------------------------------------------- 1 | name: "Publish every Git push to main to FlakeHub" 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | jobs: 7 | flakehub-publish: 8 | runs-on: "ubuntu-latest" 9 | permissions: 10 | id-token: "write" 11 | contents: "read" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: "DeterminateSystems/nix-installer-action@main" 15 | - uses: "DeterminateSystems/flakehub-push@main" 16 | with: 17 | name: "ngi-nix/weblate" 18 | rolling: true 19 | visibility: "public" 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: "Publish a flake to flakestry" 2 | on: 3 | push: 4 | tags: 5 | - "v?[0-9]+.[0-9]+.[0-9]+" 6 | - "v?[0-9]+.[0-9]+" 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: "The existing tag to publish" 11 | type: "string" 12 | required: true 13 | jobs: 14 | publish-flake: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | id-token: "write" 18 | contents: "read" 19 | steps: 20 | - uses: flakestry/flakestry-publish@main 21 | with: 22 | version: "${{ inputs.tag || github.ref_name }}" 23 | -------------------------------------------------------------------------------- /longer-celery-wait-time.patch: -------------------------------------------------------------------------------- 1 | diff --git a/weblate/utils/checks.py b/weblate/utils/checks.py 2 | index 3b97c8849c..af4ec1f1c6 100644 3 | --- a/weblate/utils/checks.py 4 | +++ b/weblate/utils/checks.py 5 | @@ -254,7 +254,10 @@ def check_celery(app_configs, **kwargs): 6 | heartbeat = cache.get("celery_heartbeat") 7 | loaded = cache.get("celery_loaded") 8 | now = time.monotonic() 9 | - if loaded and now - loaded > 60 and (not heartbeat or now - heartbeat > 600): 10 | + print(f"heartbeat: {heartbeat}") 11 | + print(f"loaded: {loaded}") 12 | + print(f"now: {now}") 13 | + if loaded and now - loaded > 240 and (not heartbeat or now - heartbeat > 600): 14 | errors.append( 15 | weblate_check( 16 | "weblate.C030", 17 | -------------------------------------------------------------------------------- /cache.lock.patch: -------------------------------------------------------------------------------- 1 | diff --git a/weblate/utils/lock.py b/weblate/utils/lock.py 2 | index 53c1486bc9..a0a5fc5a74 100644 3 | --- a/weblate/utils/lock.py 4 | +++ b/weblate/utils/lock.py 5 | @@ -43,8 +43,6 @@ class WeblateLock: 6 | self._name = self._format_template(cache_template) 7 | self._lock = cache.lock( 8 | key=self._name, 9 | - expire=3600, 10 | - auto_renewal=True, 11 | ) 12 | self._enter_implementation = self._enter_redis 13 | else: 14 | @@ -62,7 +60,7 @@ class WeblateLock: 15 | 16 | def _enter_redis(self): 17 | try: 18 | - lock_result = self._lock.acquire(timeout=self._timeout) 19 | + lock_result = self._lock.acquire() 20 | except AlreadyAcquired: 21 | return 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **This effort was superseeded [by the NixOS module in this PR](https://github.com/NixOS/nixpkgs/pull/325541). Please use it's branch or, as soon as it's merged, the official NixOS module.** 2 | 3 | # About this flake 4 | 5 | This Nix flake packages [Weblate](https://weblate.org/en/), a web based translation tool. It is fully usable and tested regularly. 6 | 7 | # Usage 8 | 9 | The primary use of this flake is deploying Weblate on NixOS. For that you would use the NixOS module available in `.#nixosModule`. 10 | 11 | If you have that module available in your NixOS config, configuration is straightforward. See e.g. this example config: 12 | 13 | ```nix 14 | { config, lib, pkgs, ... }: { 15 | 16 | services.weblate = { 17 | enable = true; 18 | localDomain = "weblate.example.org"; 19 | # E.g. use `base64 /dev/urandom | head -c50` to generate one. 20 | djangoSecretKeyFile = "/path/to/secret"; 21 | smtp = { 22 | createLocally = true; 23 | user = "weblate@example.org"; 24 | passwordFile = "/path/to/secret"; 25 | }; 26 | }; 27 | 28 | } 29 | ``` 30 | 31 | 32 | # Putting Weblate into Nixpkgs 33 | 34 | There is an [ongoing effort to merge the Weblate module and package into Nixpkgs](https://github.com/NixOS/nixpkgs/pull/325541). If you want to support this, your review would be welcome. 35 | 36 | -------------------------------------------------------------------------------- /integration-test.nix: -------------------------------------------------------------------------------- 1 | { nixpkgs, weblateModule }: 2 | { pkgs, ... }: 3 | let 4 | certs = import "${nixpkgs}/nixos/tests/common/acme/server/snakeoil-certs.nix"; 5 | serverDomain = certs.domain; 6 | admin = { 7 | username = "admin"; 8 | password = "snakeoilpass"; 9 | }; 10 | # An API token that we manually insert into the db as a valid one. 11 | apiToken = "OVJh65sXaAfQMZ4NTcIGbFZIyBZbEZqWTi7azdDf"; 12 | in 13 | { 14 | name = "weblate"; 15 | meta.maintainers = with pkgs.lib.maintainers; [ erictapen ]; 16 | 17 | nodes.server = { config, pkgs, lib, ... }: { 18 | virtualisation.memorySize = 2048; 19 | 20 | services.postgresql.package = pkgs.postgresql_14; 21 | 22 | imports = [ weblateModule ]; 23 | 24 | services.weblate = { 25 | enable = true; 26 | localDomain = "${serverDomain}"; 27 | djangoSecretKeyFile = pkgs.writeText "weblate-django-secret" "thisissnakeoilsecret"; 28 | smtp = { 29 | createLocally = true; 30 | user = "weblate@${serverDomain}"; 31 | passwordFile = pkgs.writeText "weblate-smtp-pass" "thisissnakeoilpassword"; 32 | }; 33 | }; 34 | 35 | services.nginx.virtualHosts."${serverDomain}" = { 36 | enableACME = lib.mkForce false; 37 | sslCertificate = certs."${serverDomain}".cert; 38 | sslCertificateKey = certs."${serverDomain}".key; 39 | }; 40 | 41 | services.postfix = { 42 | enableSubmission = true; 43 | enableSubmissions = true; 44 | submissionsOptions = { 45 | smtpd_sasl_auth_enable = "yes"; 46 | smtpd_client_restrictions = "permit"; 47 | }; 48 | # sslKey = certs."${serverDomain}".key; 49 | # sslCert = certs."${serverDomain}".cert; 50 | }; 51 | 52 | security.pki.certificateFiles = [ certs.ca.cert ]; 53 | 54 | networking.hosts."::1" = [ "${serverDomain}" ]; 55 | networking.firewall.allowedTCPPorts = [ 80 443 ]; 56 | 57 | # We need weblate-env available to the root user. 58 | environment.systemPackages = config.users.users.weblate.packages; 59 | users.users.weblate.shell = pkgs.bashInteractive; 60 | }; 61 | 62 | nodes.client = { pkgs, nodes, ... }: { 63 | environment.systemPackages = [ pkgs.wlc ]; 64 | 65 | environment.etc."xdg/weblate".text = '' 66 | [weblate] 67 | url = https://${serverDomain}/api/ 68 | key = ${apiToken} 69 | ''; 70 | 71 | networking.hosts."${nodes.server.networking.primaryIPAddress}" = [ "${serverDomain}" ]; 72 | 73 | security.pki.certificateFiles = [ certs.ca.cert ]; 74 | }; 75 | 76 | testScript = '' 77 | import json 78 | 79 | start_all() 80 | server.wait_for_unit("weblate.socket") 81 | server.wait_until_succeeds("curl -f https://${serverDomain}/") 82 | server.succeed("sudo -iu weblate -- weblate-env weblate createadmin --username ${admin.username} --password ${admin.password} --email weblate@example.org") 83 | 84 | # It's easier to replace the generated API token with a predefined one than 85 | # to extract it at runtime. 86 | server.succeed("sudo -iu weblate -- psql -d weblate -c \"UPDATE authtoken_token SET key = '${apiToken}' WHERE user_id = (SELECT id FROM weblate_auth_user WHERE username = 'admin');\"") 87 | 88 | client.wait_for_unit("multi-user.target") 89 | 90 | # Test the official Weblate client wlc. 91 | # client.wait_until_succeeds("wlc --debug list-projects") 92 | 93 | def call_wl_api(arg): 94 | (rv, result) = client.execute("curl -H \"Content-Type: application/json\" -H \"Authorization: Token ${apiToken}\" https://${serverDomain}/api/{}".format(arg)) 95 | assert rv == 0 96 | print(result) 97 | 98 | call_wl_api("users/ --data '{}'".format( 99 | json.dumps( 100 | {"username": "test1", 101 | "full_name": "test1", 102 | "email": "test1@example.org" 103 | }))) 104 | 105 | # server.wait_for_unit("postfix.service") 106 | # The goal is for this to succeed, but there are still some checks failing. 107 | # server.succeed("sudo -iu weblate -- weblate-env weblate check --deploy") 108 | 109 | ''; 110 | } 111 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "aeidon-src": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1674321022, 7 | "narHash": "sha256-XMF1ra7ntEdp9JN458kRhWNlLbdPP1ip9S2TelMeMv0=", 8 | "owner": "otsaloma", 9 | "repo": "gaupol", 10 | "rev": "60cab543e355e0577d3553f8b998853f56476e55", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "otsaloma", 15 | "ref": "1.12", 16 | "repo": "gaupol", 17 | "type": "github" 18 | } 19 | }, 20 | "flake-utils": { 21 | "inputs": { 22 | "systems": "systems" 23 | }, 24 | "locked": { 25 | "lastModified": 1694529238, 26 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 27 | "owner": "numtide", 28 | "repo": "flake-utils", 29 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "numtide", 34 | "repo": "flake-utils", 35 | "type": "github" 36 | } 37 | }, 38 | "flake-utils_2": { 39 | "inputs": { 40 | "systems": "systems_2" 41 | }, 42 | "locked": { 43 | "lastModified": 1694529238, 44 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 45 | "owner": "numtide", 46 | "repo": "flake-utils", 47 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "numtide", 52 | "repo": "flake-utils", 53 | "type": "github" 54 | } 55 | }, 56 | "nix-github-actions": { 57 | "inputs": { 58 | "nixpkgs": [ 59 | "poetry2nix", 60 | "nixpkgs" 61 | ] 62 | }, 63 | "locked": { 64 | "lastModified": 1698974481, 65 | "narHash": "sha256-yPncV9Ohdz1zPZxYHQf47S8S0VrnhV7nNhCawY46hDA=", 66 | "owner": "nix-community", 67 | "repo": "nix-github-actions", 68 | "rev": "4bb5e752616262457bc7ca5882192a564c0472d2", 69 | "type": "github" 70 | }, 71 | "original": { 72 | "owner": "nix-community", 73 | "repo": "nix-github-actions", 74 | "type": "github" 75 | } 76 | }, 77 | "nixpkgs": { 78 | "locked": { 79 | "lastModified": 1699099776, 80 | "narHash": "sha256-X09iKJ27mGsGambGfkKzqvw5esP1L/Rf8H3u3fCqIiU=", 81 | "owner": "NixOS", 82 | "repo": "nixpkgs", 83 | "rev": "85f1ba3e51676fa8cc604a3d863d729026a6b8eb", 84 | "type": "github" 85 | }, 86 | "original": { 87 | "owner": "NixOS", 88 | "ref": "nixos-unstable", 89 | "repo": "nixpkgs", 90 | "type": "github" 91 | } 92 | }, 93 | "poetry2nix": { 94 | "inputs": { 95 | "flake-utils": "flake-utils_2", 96 | "nix-github-actions": "nix-github-actions", 97 | "nixpkgs": [ 98 | "nixpkgs" 99 | ], 100 | "systems": "systems_3", 101 | "treefmt-nix": "treefmt-nix" 102 | }, 103 | "locked": { 104 | "lastModified": 1699231189, 105 | "narHash": "sha256-sW+/iiWdew5mftukqVm7AXPimyAWd6dMOfDkqnyBIec=", 106 | "owner": "nix-community", 107 | "repo": "poetry2nix", 108 | "rev": "8810f7d31d4d8372f764d567ea140270745fe173", 109 | "type": "github" 110 | }, 111 | "original": { 112 | "owner": "nix-community", 113 | "repo": "poetry2nix", 114 | "type": "github" 115 | } 116 | }, 117 | "root": { 118 | "inputs": { 119 | "aeidon-src": "aeidon-src", 120 | "flake-utils": "flake-utils", 121 | "nixpkgs": "nixpkgs", 122 | "poetry2nix": "poetry2nix", 123 | "weblate": "weblate" 124 | } 125 | }, 126 | "systems": { 127 | "locked": { 128 | "lastModified": 1681028828, 129 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 130 | "owner": "nix-systems", 131 | "repo": "default", 132 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 133 | "type": "github" 134 | }, 135 | "original": { 136 | "owner": "nix-systems", 137 | "repo": "default", 138 | "type": "github" 139 | } 140 | }, 141 | "systems_2": { 142 | "locked": { 143 | "lastModified": 1681028828, 144 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 145 | "owner": "nix-systems", 146 | "repo": "default", 147 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 148 | "type": "github" 149 | }, 150 | "original": { 151 | "owner": "nix-systems", 152 | "repo": "default", 153 | "type": "github" 154 | } 155 | }, 156 | "systems_3": { 157 | "locked": { 158 | "lastModified": 1681028828, 159 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 160 | "owner": "nix-systems", 161 | "repo": "default", 162 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 163 | "type": "github" 164 | }, 165 | "original": { 166 | "id": "systems", 167 | "type": "indirect" 168 | } 169 | }, 170 | "treefmt-nix": { 171 | "inputs": { 172 | "nixpkgs": [ 173 | "poetry2nix", 174 | "nixpkgs" 175 | ] 176 | }, 177 | "locked": { 178 | "lastModified": 1698438538, 179 | "narHash": "sha256-AWxaKTDL3MtxaVTVU5lYBvSnlspOS0Fjt8GxBgnU0Do=", 180 | "owner": "numtide", 181 | "repo": "treefmt-nix", 182 | "rev": "5deb8dc125a9f83b65ca86cf0c8167c46593e0b1", 183 | "type": "github" 184 | }, 185 | "original": { 186 | "owner": "numtide", 187 | "repo": "treefmt-nix", 188 | "type": "github" 189 | } 190 | }, 191 | "weblate": { 192 | "flake": false, 193 | "locked": { 194 | "lastModified": 1694767736, 195 | "narHash": "sha256-GVKSJZAiH1Sfr9n0zSK+EXqWqejrNYHj1OFtZQPbMa4=", 196 | "owner": "WeblateOrg", 197 | "repo": "weblate", 198 | "rev": "d57ba204e4943d1fe1b16f9206cfdc5dbf3d1bc8", 199 | "type": "github" 200 | }, 201 | "original": { 202 | "owner": "WeblateOrg", 203 | "ref": "weblate-5.0.2", 204 | "repo": "weblate", 205 | "type": "github" 206 | } 207 | } 208 | }, 209 | "root": "root", 210 | "version": 7 211 | } 212 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | # Minimum requirements for the build system to execute. 3 | requires = ["poetry-core>=1.0.0", "setuptools", "wheel", "translate-toolkit"] # PEP 508 specifications. 4 | build-backend = "poetry.core.masonry.api" 5 | 6 | [tool.black] 7 | target-version = ['py39'] 8 | 9 | [tool.codespell] 10 | skip = '*.po,*.pot,*.json,*.tmx,*.tbx,yarn.lock,known_hosts' 11 | 12 | [tool.pylint.main] 13 | disable = [ 14 | "C", 15 | "W", 16 | "R", 17 | "I", 18 | "no-member", 19 | "not-a-mapping", 20 | "unsubscriptable-object", 21 | "unsupported-membership-test", 22 | "not-an-iterable", 23 | "unsupported-binary-operation", 24 | "c-extension-no-member", 25 | "not-callable", 26 | "invalid-str-returned", 27 | "raising-bad-type", 28 | "no-name-in-module", 29 | "import-error" 30 | ] 31 | extension-pkg-whitelist = ["siphashc"] 32 | ignore = [ 33 | "migrations", 34 | "settings.py", 35 | "settings_test.py", 36 | ".git", 37 | "test-repos", 38 | "repos", 39 | "build", 40 | ".venv" 41 | ] 42 | 43 | [tool.ruff] 44 | format = "github" 45 | # CONFIG - intentional configuration 46 | # TODO - needs decision whether intention, add noqa tags or fix 47 | # WONTFIX - not fixable in current codebase, might be better to go for noqa 48 | ignore = [ 49 | "COM", # CONFIG: No trailing commas 50 | "PT", # CONFIG: Not using pytest 51 | "D203", # CONFIG: incompatible with D211 52 | "D212", # CONFIG: incompatible with D213 53 | "FIX002", # CONFIG: we use TODO 54 | "TD002", # CONFIG: no detailed TODO documentation is required 55 | "TD003", # CONFIG: no detailed TODO documentation is required 56 | "S603", # CONFIG: `subprocess` call: check for execution of untrusted input 57 | "S607", # CONFIG: executing system installed tools 58 | "EM", # TODO: Exception strings 59 | "PTH", # TODO: Not using pathlib 60 | "FBT", # TODO: Boolean in function definition 61 | "BLE001", # WONTFIX: Do not catch blind exception: `Exception`, third-party modules do not have defined exceptions 62 | "ARG001", # TODO: Unused function argument (mostly for API compatibility) 63 | "ARG002", # TODO: Unused method argument (mostly for API compatibility) 64 | "ANN", # TODO: we are missing many annotations 65 | "D10", # TODO: we are missing many docstrings 66 | "D401", # TODO: many strings need rephrasing 67 | "TRY003", # WONTFIX: Avoid specifying long messages outside the exception class 68 | "TRY200", # TODO: Use `raise from` to specify exception cause 69 | "B904", # TODO: Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling 70 | "PERF203", # WONTFIX: This rule is only enforced for Python versions prior to 3.1 71 | "PLR0911", # WONTFIX: Too many return statements 72 | "PLR0912", # WONTFIX: Too many branches 73 | "PLR0913", # WONTFIX: Too many arguments to function call 74 | "PLR0915", # WONTFIX: Too many statements 75 | "PLR2004", # TODO: Magic value used in comparison, consider replacing 201 with a constant variable 76 | "RUF001", # WONTFIX: String contains ambiguous unicode character, we are using Unicode 77 | "RUF012", # TODO: Mutable class attributes should be annotated with `typing.ClassVar` 78 | "RUF100", # TODO: unused noqa, compatibility with flake8, can be dropped once we stop using it 79 | "E501", # WONTFIX: we accept long strings (rest is formatted by black) 80 | "PLW2901", # TODO: overwriting variables inside loop 81 | "A001", # TODO: overriding builtins (might need noqa tags) 82 | "A002", # TODO: overriding builtins (might need noqa tags) 83 | "A003", # TODO: overriding builtins (might need noqa tags) 84 | "SLF001" # TODO: Private member accessed (might need noqa tags) 85 | ] 86 | select = ["ALL"] 87 | target-version = "py39" 88 | 89 | [tool.ruff.isort] 90 | split-on-trailing-comma = false 91 | 92 | [tool.ruff.mccabe] 93 | max-complexity = 16 94 | 95 | [tool.ruff.per-file-ignores] 96 | "docs/_ext/djangodocs.py" = ["INP001"] 97 | "docs/conf.py" = ["INP001", "ERA001", "A001"] 98 | "scripts/*" = ["T201", "T203"] 99 | "weblate/*/management/commands/*.py" = ["A003"] # Needed by Django API 100 | "weblate/*/migrations/*.py" = ["C405", "E501", "N806", "DJ01"] 101 | "weblate/*/tests.py" = ["S106", "S105"] 102 | "weblate/*/tests/test_*.py" = ["S106", "S105"] 103 | "weblate/addons/management/commands/list_addons.py" = ["E501"] 104 | "weblate/addons/utils.py" = ["N806"] 105 | "weblate/auth/migrations/0018_fixup_role.py" = ["T201", "N806"] 106 | "weblate/examples/*.py" = ["INP001"] 107 | "weblate/lang/data.py" = ["E501"] 108 | "weblate/machinery/management/commands/list_machinery.py" = ["E501"] 109 | "weblate/settings_*.py" = ["F405"] 110 | "weblate/settings_docker.py" = ["ERA001"] 111 | "weblate/settings_example.py" = ["ERA001"] 112 | "weblate/trans/migrations/0103_update_source_unit.py" = ["T201", "N806"] 113 | "weblate/trans/migrations/0116_migrate_glossaries.py" = ["T201", "N806", "E501"] 114 | "weblate/trans/migrations/0127_fix_source_glossary.py" = ["T201", "N806"] 115 | "weblate/trans/migrations/0133_glossary_missing_files.py" = ["T201", "N806"] 116 | "weblate/trans/tests/test_files.py" = ["E501"] 117 | "weblate/utils/generate_secret_key.py" = ["T201"] 118 | "weblate/utils/licensedata.py" = ["E501"] 119 | "weblate/utils/locale.py" = ["B012"] 120 | 121 | [tool.poetry] 122 | name = "weblate" 123 | version = "5.0.2" 124 | description = "" 125 | authors = ["Your Name "] 126 | license = "GPLv3+" 127 | 128 | [tool.poetry.dependencies] 129 | python = ">=3.8,<3.12" 130 | translate-toolkit = ">=3.10.0,<3.11" 131 | GitPython = ">=3.1.0,<3.2" 132 | user-agents = ">=2.0,<2.3" 133 | Django = {version = ">=4.2,<4.3", extras = ["argon2"]} 134 | translation-finder = ">=2.15,<3.0" 135 | social-auth-core = {version = ">=4.3.0,<5.0.0", extras = ["openidconnect"]} 136 | djangorestframework = ">=3.14.0,<3.15" 137 | social-auth-app-django = ">=5.0.0,<6.0.0" 138 | django-redis = ">=5.0.0,<6.0" 139 | django-appconf = ">=1.0.3,<1.1" 140 | siphashc = ">=2.1,<3.0" 141 | ahocorasick-rs = ">=0.16.0,<0.18.0" 142 | borgbackup = ">=1.2.5,<1.3" 143 | charset-normalizer = ">=2.0.12,<4.0" 144 | cssselect = ">=1.2,<1.3" 145 | Cython = ">=0.29.14,<3.1" 146 | diff-match-patch = "20230430" 147 | django-compressor = ">=2.4,<5" 148 | django-cors-headers = ">=3.13.0,<4.3" 149 | django-crispy-forms = ">=2.0.0,<2.1" 150 | django-filter = ">=21.1,<23.3" 151 | filelock = "<4,>=3.12.2" 152 | hiredis = ">=1.0.1,<2.3" 153 | html2text = ">=2019.8.11,<2020.1.17" 154 | jsonschema = ">=4.5,<5" 155 | lxml = ">=4.9.1,<4.10" 156 | misaka = ">=2.1.0,<2.2" 157 | openpyxl = ">=2.6.0,<3.2,!=3.0.2" 158 | Pillow = ">=9.0.0,<10.1" 159 | pycairo = ">=1.15.3" 160 | PyGObject = ">=3.34.0" 161 | pyicumessageformat= ">=1.0.0,<1.1" 162 | pyparsing = ">=3.1.1,<3.2" 163 | python-dateutil = ">=2.8.1" 164 | python-redis-lock = { extras = ["django"], version = ">=4,<4.1" } 165 | rapidfuzz = ">=2.6.0,<3.4" 166 | requests = ">=2.31.0,<2.32" 167 | sentry-sdk = ">=1.25.0,<2" 168 | weblate-language-data = ">=2022.7" 169 | weblate-schemas = "==2023.3" 170 | psycopg = ">=3.1.8,<4" 171 | psycopg-binary = ">=3.1.8,<4" 172 | celery = {extras = ["redis"], version = ">=5.2.3,<5.4"} 173 | aeidon = ">=1.10,<1.13.0" 174 | "fluent.syntax" = ">=0.18.1,<0.20" 175 | iniparse = "0.5" 176 | phply = ">=1.2.6,<1.3" 177 | Pygments = ">=2.15.0,<3.0" 178 | pygobject = ">=3.34.0" 179 | packaging = ">=22,<23.2" 180 | crispy-bootstrap3 = "2022.1" 181 | django-celery-beat = ">=2.4.0,<2.6" 182 | nh3 = ">=0.2.14,<0.3" 183 | mistletoe = ">=1.1.0,<1.3" 184 | "ruamel.yaml" = ">=0.17.2,<0.18.0" 185 | tesserocr = ">=2.6.1,<2.7.0" 186 | 187 | # TODO remove 188 | hatch-fancy-pypi-readme = "*" 189 | 190 | [tool.poetry.dev-dependencies] 191 | 192 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Weblate package and module"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | poetry2nix.url = "github:nix-community/poetry2nix"; 8 | poetry2nix.inputs.nixpkgs.follows = "nixpkgs"; 9 | weblate.url = "github:WeblateOrg/weblate/weblate-5.0.2"; 10 | weblate.flake = false; 11 | aeidon-src.url = "github:otsaloma/gaupol/1.12"; 12 | aeidon-src.flake = false; 13 | }; 14 | 15 | outputs = { self, nixpkgs, flake-utils, weblate, aeidon-src, poetry2nix }: 16 | let 17 | systems = [ "x86_64-linux" "aarch64-linux" ]; 18 | inherit (flake-utils.lib) eachSystem; 19 | in 20 | eachSystem systems 21 | (system: 22 | let 23 | pkgs = nixpkgs.legacyPackages.${system}; 24 | poetry2nix_instanciated = poetry2nix.lib.mkPoetry2Nix { inherit pkgs; }; 25 | in 26 | { 27 | 28 | packages = 29 | { 30 | default = self.packages.${system}.weblate; 31 | weblate = poetry2nix_instanciated.mkPoetryApplication { 32 | src = weblate; 33 | pyproject = ./pyproject.toml; 34 | poetrylock = ./poetry.lock; 35 | patches = [ 36 | # The default timeout for the celery check is much too short upstream, so 37 | # we increase it. I guess this is due to the fact that we test the setup 38 | # very early into the initialization of the server, so the load might be 39 | # higher compared to production setups? 40 | ./longer-celery-wait-time.patch 41 | # FIXME This shouldn't be necessary and probably has to do with some dependency mismatch. 42 | ./cache.lock.patch 43 | ]; 44 | meta = with pkgs.lib; { 45 | description = "Web based translation tool with tight version control integration"; 46 | homepage = "https://weblate.org/"; 47 | license = licenses.gpl3Plus; 48 | maintainers = with maintainers; [ erictapen ]; 49 | }; 50 | overrides = poetry2nix_instanciated.overrides.withDefaults ( 51 | self: super: { 52 | aeidon = super.aeidon.overridePythonAttrs (old: { 53 | src = aeidon-src; 54 | nativeBuildInputs = [ pkgs.gettext self.flake8 ]; 55 | buildInputs = [ pkgs.isocodes ]; 56 | installPhase = '' 57 | ${self.python.interpreter} setup.py --without-gaupol install --prefix=$out 58 | ''; 59 | }); 60 | fluent-syntax = super.fluent-syntax.overridePythonAttrs (old: { 61 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 62 | }); 63 | phply = super.phply.overridePythonAttrs (old: { 64 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 65 | }); 66 | pygobject = super.pygobject.overridePythonAttrs (old: { 67 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 68 | }); 69 | pyicumessageformat = super.pyicumessageformat.overridePythonAttrs (old: { 70 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 71 | }); 72 | borgbackup = super.borgbackup.overridePythonAttrs (old: { 73 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools-scm ]; 74 | }); 75 | siphashc = super.siphashc.overridePythonAttrs (old: { 76 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 77 | }); 78 | translate-toolkit = super.translate-toolkit.overridePythonAttrs (old: { 79 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 80 | }); 81 | weblate-language-data = super.weblate-language-data.overridePythonAttrs (old: { 82 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 83 | }); 84 | translation-finder = super.translation-finder.overridePythonAttrs (old: { 85 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 86 | }); 87 | weblate-schemas = super.weblate-schemas.overridePythonAttrs (old: { 88 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 89 | }); 90 | diff-match-patch = super.diff-match-patch.overridePythonAttrs (old: { 91 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.flit-core ]; 92 | }); 93 | editables = super.editables.overridePythonAttrs (old: { 94 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.flit-core ]; 95 | }); 96 | nh3 = super.nh3.overridePythonAttrs (old: { 97 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ 98 | pkgs.maturin 99 | pkgs.rustPlatform.maturinBuildHook 100 | pkgs.rustPlatform.cargoSetupHook 101 | ]; 102 | cargoDeps = 103 | let 104 | getCargoHash = version: { 105 | "0.2.14" = "sha256-EzlwSic1Qgs4NZAde/KWg0Qjs+PNEPcnE8HyIPoYZQ0="; 106 | }.${version}; 107 | in 108 | pkgs.rustPlatform.fetchCargoTarball { 109 | inherit (old) src; 110 | name = "${old.pname}-${old.version}"; 111 | hash = getCargoHash old.version; 112 | }; 113 | }); 114 | crispy-bootstrap3 = super.crispy-bootstrap3.overridePythonAttrs (old: { 115 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ self.setuptools ]; 116 | }); 117 | psycopg = super.psycopg.overridePythonAttrs ( 118 | old: { 119 | buildInputs = (old.buildInputs or [ ]) 120 | ++ pkgs.lib.optional pkgs.stdenv.isDarwin pkgs.openssl; 121 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.postgresql ]; 122 | } 123 | ); 124 | tesserocr = super.tesserocr.overridePythonAttrs ( 125 | old: { 126 | buildInputs = (old.buildInputs or [ ]) ++ [ pkgs.leptonica pkgs.tesseract ]; 127 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ pkgs.pkg-config ]; 128 | } 129 | ); 130 | ahocorasick-rs = super.ahocorasick-rs.overridePythonAttrs ( 131 | old: { 132 | nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ [ 133 | pkgs.rustPlatform.maturinBuildHook 134 | pkgs.rustPlatform.cargoSetupHook 135 | 136 | ]; 137 | cargoDeps = pkgs.rustPlatform.fetchCargoTarball { 138 | inherit (old) src; 139 | name = "${old.pname}-${old.version}"; 140 | hash = "sha256-/sel54PV58y6oUgIzHXSCL4RMljPL9kZ6ER/pRTAjAI="; 141 | }; 142 | 143 | } 144 | ); 145 | } 146 | ); 147 | }; 148 | }; 149 | 150 | checks = { 151 | integrationTest = 152 | let 153 | # As pkgs doesn't contain the weblate package and module, we have to 154 | # evaluate Nixpkgs again. 155 | pkgsWeblate = import nixpkgs { 156 | inherit system; 157 | overlays = [ self.overlays.default ]; 158 | }; 159 | in 160 | pkgsWeblate.nixosTest (import ./integration-test.nix { 161 | inherit nixpkgs; 162 | weblateModule = self.nixosModules.weblate; 163 | }); 164 | package = self.packages.${system}.weblate; 165 | }; 166 | 167 | }) // { 168 | 169 | nixosModules.weblate = import ./module.nix; 170 | 171 | overlays.default = final: prev: { 172 | inherit (self.packages.${prev.system}) weblate; 173 | }; 174 | }; 175 | } 176 | -------------------------------------------------------------------------------- /module.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, ... }: 2 | 3 | let 4 | cfg = config.services.weblate; 5 | 6 | # This extends and overrides the weblate/settings_example.py code found in upstream. 7 | weblateConfig = '' 8 | 9 | # This was autogenerated by the NixOS module. 10 | 11 | SITE_TITLE = "Weblate" 12 | SITE_DOMAIN = "${cfg.localDomain}" 13 | # TLS terminates at the reverse proxy, but this setting controls how links to weblate are generated. 14 | ENABLE_HTTPS = True 15 | DATA_DIR = "/var/lib/weblate" 16 | STATIC_ROOT = "${pkgs.weblate}/lib/${pkgs.python3.libPrefix}/site-packages/weblate/static/" 17 | MEDIA_ROOT = "/var/lib/weblate/media" 18 | COMPRESS_ROOT = "/var/lib/weblate/compressor-cache/" 19 | DEBUG = False 20 | 21 | DATABASES = { 22 | "default": { 23 | "ENGINE": "django.db.backends.postgresql", 24 | "HOST": "/run/postgresql", 25 | "NAME": "weblate", 26 | "USER": "weblate", 27 | "PASSWORD": "", 28 | "PORT": "" 29 | } 30 | } 31 | 32 | with open("${cfg.djangoSecretKeyFile}") as f: 33 | SECRET_KEY = f.read().rstrip("\n") 34 | 35 | CACHES = { 36 | "default": { 37 | "BACKEND": "django_redis.cache.RedisCache", 38 | "LOCATION": "unix://${config.services.redis.servers.weblate.unixSocket}", 39 | "OPTIONS": { 40 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 41 | "PARSER_CLASS": "redis.connection.HiredisParser", 42 | "PASSWORD": None, 43 | "CONNECTION_POOL_KWARGS": {}, 44 | }, 45 | "KEY_PREFIX": "weblate", 46 | }, 47 | "avatar": { 48 | "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", 49 | "LOCATION": "/var/lib/weblate/avatar-cache", 50 | "TIMEOUT": 86400, 51 | "OPTIONS": {"MAX_ENTRIES": 1000}, 52 | } 53 | } 54 | 55 | ADMINS = (("Weblate Admin", "${cfg.smtp.user}"),) 56 | 57 | EMAIL_HOST = "${cfg.smtp.host}" 58 | EMAIL_USE_TLS = True 59 | EMAIL_HOST_USER = "${cfg.smtp.user}" 60 | SERVER_EMAIL = "${cfg.smtp.user}" 61 | DEFAULT_FROM_EMAIL = "${cfg.smtp.user}" 62 | EMAIL_PORT = 587 63 | with open("${cfg.smtp.passwordFile}") as f: 64 | EMAIL_HOST_PASSWORD = f.read().rstrip("\n") 65 | 66 | CELERY_TASK_ALWAYS_EAGER = False 67 | CELERY_BROKER_URL = "redis+socket://${config.services.redis.servers.weblate.unixSocket}" 68 | CELERY_RESULT_BACKEND = CELERY_BROKER_URL 69 | 70 | ${cfg.extraConfig} 71 | ''; 72 | settings_py = pkgs.runCommand "weblate_settings.py" { } '' 73 | mkdir -p $out 74 | cat ${pkgs.weblate}/lib/${pkgs.python3.libPrefix}/site-packages/weblate/settings_example.py > $out/settings.py 75 | cat >> $out/settings.py <settings.py Weblate config file. 152 | ''; 153 | }; 154 | 155 | smtp = { 156 | user = lib.mkOption { 157 | description = "SMTP login name."; 158 | example = "weblate@weblate.example.org"; 159 | type = lib.types.str; 160 | }; 161 | 162 | host = lib.mkOption { 163 | description = "SMTP host used when sending emails to users."; 164 | type = lib.types.str; 165 | default = "127.0.0.1"; 166 | }; 167 | 168 | createLocally = lib.mkOption { 169 | description = "Configure local Postfix SMTP server for Weblate."; 170 | type = lib.types.bool; 171 | default = true; 172 | }; 173 | passwordFile = lib.mkOption { 174 | description = '' 175 | Location of a file containing the SMTP password. 176 | 177 | This should be a string, not a nix path, since nix paths are copied into the world-readable nix store. 178 | ''; 179 | type = lib.types.path; 180 | }; 181 | }; 182 | 183 | }; 184 | }; 185 | 186 | config = lib.mkIf cfg.enable { 187 | 188 | assertions = [ 189 | { 190 | assertion = cfg.smtp.createLocally -> cfg.smtp.host == "127.0.0.1"; 191 | message = ''services.weblate.smtp.host should be "127.0.0.1" if you want to to use services.weblate.smtp.createLocally.''; 192 | } 193 | { 194 | assertion = builtins.compareVersions config.services.postgresql.package.version "15.0" == -1; 195 | message = "Weblate doesn't work with PostgreSQL 15 and higher right now (currently ${config.services.postgresql.package.version}). This is a bug in the NixOS module, so feel free to open a PR."; 196 | } 197 | ]; 198 | 199 | services.nginx = { 200 | enable = true; 201 | virtualHosts."${cfg.localDomain}" = { 202 | 203 | forceSSL = true; 204 | enableACME = true; 205 | 206 | locations = { 207 | "= /favicon.ico".alias = "${pkgs.weblate}/lib/${pkgs.python3.libPrefix}/site-packages/weblate/static/favicon.ico"; 208 | "/static/".alias = "${pkgs.weblate}/lib/${pkgs.python3.libPrefix}/site-packages/weblate/static/"; 209 | "/static/CACHE/".alias = "/var/lib/weblate/compressor-cache/CACHE/"; 210 | "/media/".alias = "/var/lib/weblate/media/"; 211 | "/".extraConfig = '' 212 | # Needed for long running operations in admin interface 213 | uwsgi_read_timeout 3600; 214 | # Adjust based to uwsgi configuration: 215 | uwsgi_pass unix:///run/weblate.socket; 216 | # uwsgi_pass 127.0.0.1:8080; 217 | ''; 218 | }; 219 | 220 | }; 221 | }; 222 | 223 | systemd.services.weblate-postgresql-setup = { 224 | description = "Weblate PostgreSQL setup"; 225 | wantedBy = [ "multi-user.target" ]; 226 | after = [ "postgresql.service" ]; 227 | serviceConfig = { 228 | Type = "oneshot"; 229 | User = "postgres"; 230 | Group = "postgres"; 231 | ExecStart = '' 232 | ${pkgs.postgresql}/bin/psql weblate -c "CREATE EXTENSION IF NOT EXISTS pg_trgm" 233 | ''; 234 | }; 235 | }; 236 | 237 | systemd.services.weblate-migrate = { 238 | description = "Weblate migration"; 239 | wantedBy = [ 240 | "weblate.service" 241 | "multi-user.target" 242 | ]; 243 | after = [ 244 | "postgresql.service" 245 | "weblate-postgresql-setup.service" 246 | ]; 247 | inherit environment; 248 | path = weblatePath; 249 | serviceConfig = { 250 | Type = "oneshot"; 251 | # WorkingDirectory = pkgs.weblate; 252 | StateDirectory = "weblate"; 253 | User = "weblate"; 254 | Group = "weblate"; 255 | ExecStart = "${pkgs.weblate}/bin/weblate migrate --noinput"; 256 | }; 257 | }; 258 | 259 | systemd.services.weblate-celery = { 260 | description = "Weblate Celery"; 261 | wantedBy = [ "multi-user.target" ]; 262 | after = [ 263 | "network.target" 264 | "redis.service" 265 | "postgresql.service" 266 | ]; 267 | environment = environment // { 268 | CELERY_WORKER_RUNNING = "1"; 269 | }; 270 | path = weblatePath; 271 | # Recommendations from: 272 | # https://github.com/WeblateOrg/weblate/blob/main/weblate/examples/celery-weblate.service 273 | serviceConfig = 274 | let 275 | # We have to push %n through systemd's replacement, therefore %%n. 276 | pidFile = "/run/celery/%%n.pid"; 277 | nodes = "celery notify memory backup translate"; 278 | cmd = verb: '' 279 | ${pkgs.weblate.dependencyEnv}/bin/celery multi ${verb} \ 280 | ${nodes} \ 281 | -A "weblate.utils" \ 282 | --pidfile=${pidFile} \ 283 | --logfile=/var/log/celery/%%n%%I.log \ 284 | --loglevel=DEBUG \ 285 | --beat:celery \ 286 | --queues:celery=celery \ 287 | --prefetch-multiplier:celery=4 \ 288 | --queues:notify=notify \ 289 | --prefetch-multiplier:notify=10 \ 290 | --queues:memory=memory \ 291 | --prefetch-multiplier:memory=10 \ 292 | --queues:translate=translate \ 293 | --prefetch-multiplier:translate=4 \ 294 | --concurrency:backup=1 \ 295 | --queues:backup=backup \ 296 | --prefetch-multiplier:backup=2 297 | ''; 298 | in 299 | { 300 | Type = "forking"; 301 | User = "weblate"; 302 | Group = "weblate"; 303 | WorkingDirectory = "${pkgs.weblate}/lib/${pkgs.python3.libPrefix}/site-packages/weblate/"; 304 | RuntimeDirectory = "celery"; 305 | RuntimeDirectoryPreserve = "restart"; 306 | LogsDirectory = "celery"; 307 | ExecStart = cmd "start"; 308 | ExecReload = cmd "restart"; 309 | ExecStop = '' 310 | ${pkgs.weblate.dependencyEnv}/bin/celery multi stopwait \ 311 | ${nodes} \ 312 | --pidfile=${pidFile} 313 | ''; 314 | Restart = "always"; 315 | }; 316 | }; 317 | 318 | systemd.services.weblate = { 319 | description = "Weblate uWSGI app"; 320 | after = [ 321 | "network.target" 322 | "postgresql.service" 323 | "redis.service" 324 | "weblate-migrate.service" 325 | "weblate-postgresql-setup.service" 326 | ]; 327 | requires = [ 328 | "weblate-migrate.service" 329 | "weblate-postgresql-setup.service" 330 | "weblate-celery.service" 331 | "weblate.socket" 332 | ]; 333 | inherit environment; 334 | path = weblatePath; 335 | serviceConfig = { 336 | Type = "notify"; 337 | NotifyAccess = "all"; 338 | ExecStart = 339 | let 340 | uwsgi = pkgs.uwsgi.override { plugins = [ "python3" ]; }; 341 | jsonConfig = pkgs.writeText "uwsgi.json" (builtins.toJSON uwsgiConfig); 342 | in 343 | "${uwsgi}/bin/uwsgi --json ${jsonConfig}"; 344 | Restart = "on-failure"; 345 | KillSignal = "SIGTERM"; 346 | WorkingDirectory = pkgs.weblate; 347 | StateDirectory = "weblate"; 348 | RuntimeDirectory = "weblate"; 349 | User = "weblate"; 350 | Group = "weblate"; 351 | }; 352 | }; 353 | 354 | systemd.sockets.weblate = { 355 | before = [ "nginx.service" ]; 356 | wantedBy = [ "sockets.target" ]; 357 | socketConfig = { 358 | ListenStream = "/run/weblate.socket"; 359 | SocketUser = "weblate"; 360 | SocketGroup = "weblate"; 361 | SocketMode = "770"; 362 | }; 363 | }; 364 | 365 | services.postfix = lib.mkIf cfg.smtp.createLocally { 366 | enable = true; 367 | }; 368 | 369 | services.redis.servers.weblate = { 370 | enable = true; 371 | user = "weblate"; 372 | unixSocket = "/run/redis-weblate/redis.sock"; 373 | unixSocketPerm = 770; 374 | }; 375 | 376 | services.postgresql = { 377 | enable = true; 378 | ensureUsers = [ 379 | { 380 | name = "weblate"; 381 | ensurePermissions."DATABASE weblate" = "ALL PRIVILEGES"; 382 | } 383 | ]; 384 | ensureDatabases = [ "weblate" ]; 385 | }; 386 | 387 | users.users.weblate = { 388 | isSystemUser = true; 389 | group = "weblate"; 390 | packages = [ weblate-env pkgs.weblate ] ++ weblatePath; 391 | 392 | # FIXME This is only here because Weblate wants to save something in this dir at runtime... 393 | createHome = true; 394 | home = "/home/weblate"; 395 | 396 | }; 397 | 398 | # TODO remove 399 | environment.systemPackages = config.users.users.weblate.packages; 400 | 401 | users.groups.weblate.members = [ config.services.nginx.user ]; 402 | }; 403 | 404 | meta.maintainers = with lib.maintainers; [ erictapen ]; 405 | 406 | } 407 | --------------------------------------------------------------------------------