diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index dbfd8d176d..0000000000 --- a/.coveragerc +++ /dev/null @@ -1,9 +0,0 @@ -[run] -source = ./InvenTree -omit = - InvenTree/manage.py - InvenTree/setup.py - InvenTree/InvenTree/middleware.py - InvenTree/InvenTree/utils.py - InvenTree/InvenTree/wsgi.py - InvenTree/users/apps.py \ No newline at end of file diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000000..43363404b3 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,25 @@ +env: + commonjs: false + browser: true + es2021: true + jquery: true +extends: + - google +parserOptions: + ecmaVersion: 12 +rules: + no-var: off + guard-for-in: off + no-trailing-spaces: off + camelcase: off + padded-blocks: off + prefer-const: off + max-len: off + require-jsdoc: off + valid-jsdoc: off + no-multiple-empty-lines: off + comma-dangle: off + prefer-spread: off + indent: + - error + - 4 diff --git a/.github/ISSUE_TEMPLATE/app_issue.md b/.github/ISSUE_TEMPLATE/app_issue.md new file mode 100644 index 0000000000..e71861394c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/app_issue.md @@ -0,0 +1,30 @@ +--- +name: App issue +about: Report a bug or issue with the InvenTree app +title: "[APP] Enter bug description" +labels: bug, app +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of the bug or issue + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to ... +2. Select ... +3. ... + +**Expected Behavior** +A clear and concise description of what you expected to happen + +**Screenshots** +If applicable, add screenshots to help explain your problem + +**Version Information** + +- App platform: *Select iOS or Android* +- App version: *Enter app version* +- Server version: *Enter server version* diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..1a75b97af0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Create a bug report to help us improve InvenTree +title: "[BUG] Enter bug description" +labels: bug, question +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Deployment Method** +Docker +Bare Metal + +**Version Information** +You can get this by going to the "About InvenTree" section in the upper right corner and cicking on to the "copy version information" diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..ca9ff88a58 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FR]" +labels: enhancement +assignees: '' + +--- + +**Is your feature request the result of a bug?** +Please link it here. + +**Problem** +A clear and concise description of what the problem is. e.g. I'm always frustrated when [...] + +**Suggested solution** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Examples of other systems** +Show how other software handles your FR if you have examples. + +**Do you want to develop this?** +If so please describe briefly how you would like to implement it (so we can give advice) and if you have experience in the needed technology (you do not need to be a pro - this is just as a information for us). diff --git a/.github/workflows/docker_build.yaml b/.github/workflows/docker_latest.yaml similarity index 90% rename from .github/workflows/docker_build.yaml rename to .github/workflows/docker_latest.yaml index ec8bdf7306..355afa5b87 100644 --- a/.github/workflows/docker_build.yaml +++ b/.github/workflows/docker_latest.yaml @@ -15,6 +15,9 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v2 + - name: Check version number + run: | + python3 ci/check_version_number.py --dev - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx diff --git a/.github/workflows/docker_stable.yaml b/.github/workflows/docker_stable.yaml new file mode 100644 index 0000000000..3d435e40da --- /dev/null +++ b/.github/workflows/docker_stable.yaml @@ -0,0 +1,42 @@ +# Build and push latest docker image on push to master branch + +name: Docker Build + +on: + push: + branches: + - 'stable' + +jobs: + + docker: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Check version number + run: | + python3 ci/check_version_number.py --release + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to Dockerhub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build and Push + uses: docker/build-push-action@v2 + with: + context: ./docker + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + target: production + build-args: + branch: stable + repository: inventree/inventree + tags: inventree/inventree:stable + - name: Image Digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/docker_publish.yaml b/.github/workflows/docker_tag.yaml similarity index 85% rename from .github/workflows/docker_publish.yaml rename to .github/workflows/docker_tag.yaml index 9f3f3d6912..b3b0c53d12 100644 --- a/.github/workflows/docker_publish.yaml +++ b/.github/workflows/docker_tag.yaml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v2 - name: Check Release tag run: | - python3 ci/check_version_number.py ${{ github.event.release.tag_name }} + python3 ci/check_version_number.py --release --tag ${{ github.event.release.tag_name }} - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx @@ -32,5 +32,7 @@ jobs: platforms: linux/amd64,linux/arm64,linux/arm/v7 push: true target: production + build-args: + tag: ${{ github.event.release.tag_name }} repository: inventree/inventree tags: inventree/inventree:${{ github.event.release.tag_name }} diff --git a/.github/workflows/html.yaml b/.github/workflows/html.yaml new file mode 100644 index 0000000000..069da7cbb4 --- /dev/null +++ b/.github/workflows/html.yaml @@ -0,0 +1,54 @@ +# Check javascript template files + +name: HTML Templates + +on: + push: + branches: + - master + + pull_request: + branches-ignore: + - l10* + +jobs: + + html: + runs-on: ubuntu-latest + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INVENTREE_DB_ENGINE: sqlite3 + INVENTREE_DB_NAME: inventree + INVENTREE_MEDIA_ROOT: ./media + INVENTREE_STATIC_ROOT: ./static + steps: + - name: Install node.js + uses: actions/setup-node@v2 + - run: npm install + - name: Checkout Code + uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.7 + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install gettext + pip3 install invoke + invoke install + invoke static + - name: Check HTML Files + run: | + npm install markuplint + npx markuplint InvenTree/build/templates/build/*.html + npx markuplint InvenTree/common/templates/common/*.html + npx markuplint InvenTree/company/templates/company/*.html + npx markuplint InvenTree/order/templates/order/*.html + npx markuplint InvenTree/part/templates/part/*.html + npx markuplint InvenTree/stock/templates/stock/*.html + npx markuplint InvenTree/templates/*.html + npx markuplint InvenTree/templates/InvenTree/*.html + npx markuplint InvenTree/templates/InvenTree/settings/*.html + diff --git a/.github/workflows/javascript.yaml b/.github/workflows/javascript.yaml index 908a87e31c..a07b516ac6 100644 --- a/.github/workflows/javascript.yaml +++ b/.github/workflows/javascript.yaml @@ -18,11 +18,33 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INVENTREE_DB_ENGINE: sqlite3 + INVENTREE_DB_NAME: inventree + INVENTREE_MEDIA_ROOT: ./media + INVENTREE_STATIC_ROOT: ./static steps: + - name: Install node.js + uses: actions/setup-node@v2 + - run: npm install - name: Checkout Code uses: actions/checkout@v2 - - name: Check Files + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.7 + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install gettext + pip3 install invoke + invoke install + invoke static + - name: Check Templated Files run: | cd ci python check_js_templates.py - \ No newline at end of file + - name: Lint Javascript Files + run: | + npm install eslint eslint-config-google + invoke render-js-files + npx eslint js_tmp/*.js \ No newline at end of file diff --git a/.github/workflows/version.yaml b/.github/workflows/version.yaml new file mode 100644 index 0000000000..6e32d9e148 --- /dev/null +++ b/.github/workflows/version.yaml @@ -0,0 +1,20 @@ +# Check that the version number format matches the current branch + +name: Version Numbering + +on: + pull_request: + branches-ignore: + - l10* + +jobs: + + check: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + - name: Check version number + run: | + python3 ci/check_version_number.py --branch ${{ github.base_ref }} diff --git a/.gitignore b/.gitignore index 5610fc4304..420524d06f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ local_settings.py # Files used for testing dummy_image.* +_tmp.csv # Sphinx files docs/_build @@ -66,8 +67,16 @@ secret_key.txt .coverage htmlcov/ +# Temporary javascript files (used for testing) +js_tmp/ + # Development files dev/ # Locale stats file locale_stats.json + +# node.js +package-lock.json +package.json +node_modules/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1024e251c1..0677e61de4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,29 +1,102 @@ -Contributions to InvenTree are welcomed - please follow the guidelines below. +Please read the contribution guidelines below, before submitting your first pull request to the InvenTree codebase. -## Feature Branches +## Branches and Versioning -No pushing to master! New featues must be submitted in a separate branch (one branch per feature). +InvenTree roughly follow the [GitLab flow](https://docs.gitlab.com/ee/topics/gitlab_flow.html) branching style, to allow simple management of multiple tagged releases, short-lived branches, and development on the main branch. -## Include Migration Files +### Version Numbering + +InvenTree version numbering follows the [semantic versioning](https://semver.org/) specification. + +### Master Branch + +The HEAD of the "main" or "master" branch of InvenTree represents the current "latest" state of code development. + +- All feature branches are merged into master +- All bug fixes are merged into master + +**No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature). + +#### Feature Branches + +Feature branches should be branched *from* the *master* branch. + +- One major feature per branch / pull request +- Feature pull requests are merged back *into* the master branch +- Features *may* also be merged into a release candidate branch + +### Stable Branch + +The HEAD of the "stable" branch represents the latest stable release code. + +- Versioned releases are merged into the "stable" branch +- Bug fix branches are made *from* the "stable" branch + +#### Release Candidate Branches + +- Release candidate branches are made from master, and merged into stable. +- RC branches are targetted at a major/minor version e.g. "0.5" +- When a release candidate branch is merged into *stable*, the release is tagged + +#### Bugfix Branches + +- If a bug is discovered in a tagged release version of InvenTree, a "bugfix" or "hotfix" branch should be made *from* that tagged release +- When approved, the branch is merged back *into* stable, with an incremented PATCH number (e.g. 0.4.1 -> 0.4.2) +- The bugfix *must* also be cherry picked into the *master* branch. + +## Migration Files Any required migration files **must** be included in the commit, or the pull-request will be rejected. If you change the underlying database schema, make sure you run `invoke migrate` and commit the migration files before submitting the PR. -## Update Translation Files +*Note: A github action checks for unstaged migration files and will reject the PR if it finds any!* -Any PRs which update translatable strings (i.e. text strings that will appear in the web-front UI) must also update the translation (locale) files to include hooks for the translated strings. +## Unit Testing -*This does not mean that all translations must be provided, but that the translation files must include locations for the translated strings to be written.* +Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage for any new features is insufficient, or the overall code coverage is decreased. -To perform this step, simply run `invoke translate` from the top level directory before submitting the PR. +The InvenTree code base makes use of [GitHub actions](https://github.com/features/actions) to run a suite of automated tests against the code base every time a new pull request is received. These actions include (but are not limited to): -## Testing +- Checking Python and Javascript code against standard style guides +- Running unit test suite +- Automated building and pushing of docker images +- Generating translation files -Any new code should be covered by unit tests - a submitted PR may not be accepted if the code coverage is decreased. +The various github actions can be found in the `./github/workflows` directory + +## Code Style + +Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR. ## Documentation New features or updates to existing features should be accompanied by user documentation. A PR with associated documentation should link to the matching PR at https://github.com/inventree/inventree-docs/ -## Code Style +## Translations -Sumbitted Python code is automatically checked against PEP style guidelines. Locally you can run `invoke style` to ensure the style checks will pass, before submitting the PR. +Any user-facing strings *must* be passed through the translation engine. + +- InvenTree code is written in English +- User translatable strings are provided in English as the primary language +- Secondary language translations are provided [via Crowdin](https://crowdin.com/project/inventree) + +*Note: Translation files are updated via GitHub actions - you do not need to compile translations files before submitting a pull request!* + +### Python Code + +For strings exposed via Python code, use the following format: + +```python +from django.utils.translation import ugettext_lazy as _ + +user_facing_string = _('This string will be exposed to the translation engine!') +``` + +### Templated Strings + +HTML and javascript files are passed through the django templating engine. Translatable strings are implemented as follows: + +```html +{% load i18n %} + +{% trans "This string will be translated" %} - this string will not! +``` \ No newline at end of file diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index feb46ee667..6456c5994f 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -32,27 +32,44 @@ class InvenTreeConfig(AppConfig): logger.info("Starting background tasks...") + # Remove successful task results from the database InvenTree.tasks.schedule_task( 'InvenTree.tasks.delete_successful_tasks', schedule_type=Schedule.DAILY, ) + # Check for InvenTree updates InvenTree.tasks.schedule_task( 'InvenTree.tasks.check_for_updates', schedule_type=Schedule.DAILY ) + # Heartbeat to let the server know the background worker is running InvenTree.tasks.schedule_task( 'InvenTree.tasks.heartbeat', schedule_type=Schedule.MINUTES, minutes=15 ) + # Keep exchange rates up to date InvenTree.tasks.schedule_task( 'InvenTree.tasks.update_exchange_rates', schedule_type=Schedule.DAILY, ) + # Remove expired sessions + InvenTree.tasks.schedule_task( + 'InvenTree.tasks.delete_expired_sessions', + schedule_type=Schedule.DAILY, + ) + + # Delete "old" stock items + InvenTree.tasks.schedule_task( + 'stock.tasks.delete_old_stock_items', + schedule_type=Schedule.MINUTES, + minutes=30, + ) + def update_exchange_rates(self): """ Update exchange rates each time the server is started, *if*: diff --git a/InvenTree/InvenTree/ci_render_js.py b/InvenTree/InvenTree/ci_render_js.py new file mode 100644 index 0000000000..62e3fc4667 --- /dev/null +++ b/InvenTree/InvenTree/ci_render_js.py @@ -0,0 +1,100 @@ +""" +Pull rendered copies of the templated +""" + +from django.http import response +from django.test import TestCase, testcases +from django.contrib.auth import get_user_model + +import os +import pathlib + + +class RenderJavascriptFiles(TestCase): + """ + A unit test to "render" javascript files. + + The server renders templated javascript files, + we need the fully-rendered files for linting and static tests. + """ + + def setUp(self): + + user = get_user_model() + + self.user = user.objects.create_user( + username='testuser', + password='testpassword', + email='user@gmail.com', + ) + + self.client.login(username='testuser', password='testpassword') + + def download_file(self, filename, prefix): + + url = os.path.join(prefix, filename) + + response = self.client.get(url) + + here = os.path.abspath(os.path.dirname(__file__)) + + output_dir = os.path.join( + here, + '..', + '..', + 'js_tmp', + ) + + output_dir = os.path.abspath(output_dir) + + if not os.path.exists(output_dir): + os.mkdir(output_dir) + + output_file = os.path.join( + output_dir, + filename, + ) + + with open(output_file, 'wb') as output: + output.write(response.content) + + def download_files(self, subdir, prefix): + here = os.path.abspath(os.path.dirname(__file__)) + + js_template_dir = os.path.join( + here, + '..', + 'templates', + 'js', + ) + + directory = os.path.join(js_template_dir, subdir) + + directory = os.path.abspath(directory) + + js_files = pathlib.Path(directory).rglob('*.js') + + n = 0 + + for f in js_files: + js = os.path.basename(f) + + self.download_file(js, prefix) + + n += 1 + + return n + + def test_render_files(self): + """ + Look for all javascript files + """ + + n = 0 + + print("Rendering javascript files...") + + n += self.download_files('translated', '/js/i18n') + n += self.download_files('dynamic', '/js/dynamic') + + print(f"Rendered {n} javascript files.") diff --git a/InvenTree/InvenTree/context.py b/InvenTree/InvenTree/context.py index 3e1f98ffc2..bd68a0182f 100644 --- a/InvenTree/InvenTree/context.py +++ b/InvenTree/InvenTree/context.py @@ -36,9 +36,14 @@ def health_status(request): 'email_configured': InvenTree.status.is_email_configured(), } + # The following keys are required to denote system health + health_keys = [ + 'django_q_running', + ] + all_healthy = True - for k in status.keys(): + for k in health_keys: if status[k] is not True: all_healthy = False diff --git a/InvenTree/InvenTree/filters.py b/InvenTree/InvenTree/filters.py new file mode 100644 index 0000000000..cd1b769646 --- /dev/null +++ b/InvenTree/InvenTree/filters.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from rest_framework.filters import OrderingFilter + + +class InvenTreeOrderingFilter(OrderingFilter): + """ + Custom OrderingFilter class which allows aliased filtering of related fields. + + To use, simply specify this filter in the "filter_backends" section. + + filter_backends = [ + InvenTreeOrderingFilter, + ] + + Then, specify a ordering_field_aliases attribute: + + ordering_field_alises = { + 'name': 'part__part__name', + 'SKU': 'part__SKU', + } + """ + + def get_ordering(self, request, queryset, view): + + ordering = super().get_ordering(request, queryset, view) + + aliases = getattr(view, 'ordering_field_aliases', None) + + # Attempt to map ordering fields based on provided aliases + if ordering is not None and aliases is not None: + """ + Ordering fields should be mapped to separate fields + """ + + for idx, field in enumerate(ordering): + + reverse = False + + if field.startswith('-'): + field = field[1:] + reverse = True + + if field in aliases: + ordering[idx] = aliases[field] + + if reverse: + ordering[idx] = '-' + ordering[idx] + + return ordering diff --git a/InvenTree/InvenTree/locale_stats.json b/InvenTree/InvenTree/locale_stats.json new file mode 100644 index 0000000000..9f003895c5 --- /dev/null +++ b/InvenTree/InvenTree/locale_stats.json @@ -0,0 +1 @@ +{"de": 95, "el": 0, "en": 0, "es": 4, "fr": 6, "he": 0, "id": 0, "it": 0, "ja": 4, "ko": 0, "nl": 0, "no": 0, "pl": 27, "ru": 6, "sv": 0, "th": 0, "tr": 32, "vi": 0, "zh": 1} \ No newline at end of file diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index 6cf29ab945..613983fe94 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -98,10 +98,12 @@ class InvenTreeMetadata(SimpleMetadata): serializer_info = super().get_serializer_info(serializer) - try: - ModelClass = serializer.Meta.model + model_class = None - model_fields = model_meta.get_field_info(ModelClass) + try: + model_class = serializer.Meta.model + + model_fields = model_meta.get_field_info(model_class) # Iterate through simple fields for name, field in model_fields.fields.items(): @@ -146,11 +148,23 @@ class InvenTreeMetadata(SimpleMetadata): if hasattr(serializer, 'instance'): instance = serializer.instance - if instance is None: - try: - instance = self.view.get_object() - except: - pass + if instance is None and model_class is not None: + # Attempt to find the instance based on kwargs lookup + kwargs = getattr(self.view, 'kwargs', None) + + if kwargs: + pk = None + + for field in ['pk', 'id', 'PK', 'ID']: + if field in kwargs: + pk = kwargs[field] + break + + if pk is not None: + try: + instance = model_class.objects.get(pk=pk) + except (ValueError, model_class.DoesNotExist): + pass if instance is not None: """ diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 3213838e78..2ca179bb40 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -5,8 +5,10 @@ Generic models which provide extra functionality over base Django model types. from __future__ import unicode_literals import os +import logging from django.db import models +from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ @@ -21,6 +23,9 @@ from mptt.exceptions import InvalidMove from .validators import validate_tree_name +logger = logging.getLogger('inventree') + + def rename_attachment(instance, filename): """ Function for renaming an attachment file. @@ -77,6 +82,72 @@ class InvenTreeAttachment(models.Model): def basename(self): return os.path.basename(self.attachment.name) + @basename.setter + def basename(self, fn): + """ + Function to rename the attachment file. + + - Filename cannot be empty + - Filename cannot contain illegal characters + - Filename must specify an extension + - Filename cannot match an existing file + """ + + fn = fn.strip() + + if len(fn) == 0: + raise ValidationError(_('Filename must not be empty')) + + attachment_dir = os.path.join( + settings.MEDIA_ROOT, + self.getSubdir() + ) + + old_file = os.path.join( + settings.MEDIA_ROOT, + self.attachment.name + ) + + new_file = os.path.join( + settings.MEDIA_ROOT, + self.getSubdir(), + fn + ) + + new_file = os.path.abspath(new_file) + + # Check that there are no directory tricks going on... + if not os.path.dirname(new_file) == attachment_dir: + logger.error(f"Attempted to rename attachment outside valid directory: '{new_file}'") + raise ValidationError(_("Invalid attachment directory")) + + # Ignore further checks if the filename is not actually being renamed + if new_file == old_file: + return + + forbidden = ["'", '"', "#", "@", "!", "&", "^", "<", ">", ":", ";", "/", "\\", "|", "?", "*", "%", "~", "`"] + + for c in forbidden: + if c in fn: + raise ValidationError(_(f"Filename contains illegal character '{c}'")) + + if len(fn.split('.')) < 2: + raise ValidationError(_("Filename missing extension")) + + if not os.path.exists(old_file): + logger.error(f"Trying to rename attachment '{old_file}' which does not exist") + return + + if os.path.exists(new_file): + raise ValidationError(_("Attachment with this filename already exists")) + + try: + os.rename(old_file, new_file) + self.attachment.name = os.path.join(self.getSubdir(), fn) + self.save() + except: + raise ValidationError(_("Error renaming file")) + class Meta: abstract = True diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index baf08e112b..0d21550f00 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -10,6 +10,8 @@ import os from decimal import Decimal +from collections import OrderedDict + from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError as DjangoValidationError @@ -46,10 +48,12 @@ class InvenTreeMoneySerializer(MoneyField): amount = None try: - if amount is not None: + if amount is not None and amount is not empty: amount = Decimal(amount) except: - raise ValidationError(_("Must be a valid number")) + raise ValidationError({ + self.field_name: _("Must be a valid number") + }) currency = data.get(get_currency_field_name(self.field_name), self.default_currency) @@ -92,9 +96,14 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): # If instance is None, we are creating a new instance if instance is None and data is not empty: - - # Required to side-step immutability of a QueryDict - data = data.copy() + + if data is None: + data = OrderedDict() + else: + new_data = OrderedDict() + new_data.update(data) + + data = new_data # Add missing fields which have default values ModelClass = self.Meta.model @@ -167,6 +176,18 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return self.instance + def update(self, instance, validated_data): + """ + Catch any django ValidationError, and re-throw as a DRF ValidationError + """ + + try: + instance = super().update(instance, validated_data) + except (ValidationError, DjangoValidationError) as exc: + raise ValidationError(detail=serializers.as_serializer_error(exc)) + + return instance + def run_validation(self, data=empty): """ Perform serializer validation. @@ -188,7 +209,10 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): # Update instance fields for attr, value in data.items(): - setattr(instance, attr, value) + try: + setattr(instance, attr, value) + except (ValidationError, DjangoValidationError) as exc: + raise ValidationError(detail=serializers.as_serializer_error(exc)) # Run a 'full_clean' on the model. # Note that by default, DRF does *not* perform full model validation! @@ -208,6 +232,22 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return data +class InvenTreeAttachmentSerializer(InvenTreeModelSerializer): + """ + Special case of an InvenTreeModelSerializer, which handles an "attachment" model. + + The only real addition here is that we support "renaming" of the attachment file. + """ + + # The 'filename' field must be present in the serializer + filename = serializers.CharField( + label=_('Filename'), + required=False, + source='basename', + allow_blank=False, + ) + + class InvenTreeAttachmentSerializerField(serializers.FileField): """ Override the DRF native FileField serializer, diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 4543b873bd..f3c166df88 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -169,6 +169,30 @@ else: logger.exception(f"Couldn't load keyfile {key_file}") sys.exit(-1) +# The filesystem location for served static files +STATIC_ROOT = os.path.abspath( + get_setting( + 'INVENTREE_STATIC_ROOT', + CONFIG.get('static_root', None) + ) +) + +if STATIC_ROOT is None: + print("ERROR: INVENTREE_STATIC_ROOT directory not defined") + sys.exit(1) + +# The filesystem location for served static files +MEDIA_ROOT = os.path.abspath( + get_setting( + 'INVENTREE_MEDIA_ROOT', + CONFIG.get('media_root', None) + ) +) + +if MEDIA_ROOT is None: + print("ERROR: INVENTREE_MEDIA_ROOT directory is not defined") + sys.exit(1) + # List of allowed hosts (default = allow all) ALLOWED_HOSTS = CONFIG.get('allowed_hosts', ['*']) @@ -189,22 +213,12 @@ if cors_opt: # Web URL endpoint for served static files STATIC_URL = '/static/' -# The filesystem location for served static files -STATIC_ROOT = os.path.abspath( - get_setting( - 'INVENTREE_STATIC_ROOT', - CONFIG.get('static_root', '/home/inventree/data/static') - ) -) - -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, 'InvenTree', 'static'), -] +STATICFILES_DIRS = [] # Translated Template settings STATICFILES_I18_PREFIX = 'i18n' STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated') -STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX +STATICFILES_I18_TRG = os.path.join(BASE_DIR, 'InvenTree', 'static_i18n') STATICFILES_DIRS.append(STATICFILES_I18_TRG) STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX) @@ -218,19 +232,11 @@ STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes') # Web URL endpoint for served media files MEDIA_URL = '/media/' -# The filesystem location for served static files -MEDIA_ROOT = os.path.abspath( - get_setting( - 'INVENTREE_MEDIA_ROOT', - CONFIG.get('media_root', '/home/inventree/data/media') - ) -) - if DEBUG: logger.info("InvenTree running in DEBUG mode") -logger.info(f"MEDIA_ROOT: '{MEDIA_ROOT}'") -logger.info(f"STATIC_ROOT: '{STATIC_ROOT}'") +logger.debug(f"MEDIA_ROOT: '{MEDIA_ROOT}'") +logger.debug(f"STATIC_ROOT: '{STATIC_ROOT}'") # Application definition @@ -320,6 +326,7 @@ TEMPLATES = [ 'django.template.context_processors.i18n', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + # Custom InvenTree context processors 'InvenTree.context.health_status', 'InvenTree.context.status_codes', 'InvenTree.context.user_roles', @@ -413,7 +420,7 @@ Configure the database backend based on the user-specified values. - The following code lets the user "mix and match" database configuration """ -logger.info("Configuring database backend:") +logger.debug("Configuring database backend:") # Extract database configuration from the config.yaml file db_config = CONFIG.get('database', {}) @@ -467,11 +474,9 @@ if db_engine in ['sqlite3', 'postgresql', 'mysql']: db_name = db_config['NAME'] db_host = db_config.get('HOST', "''") -print("InvenTree Database Configuration") -print("================================") -print(f"ENGINE: {db_engine}") -print(f"NAME: {db_name}") -print(f"HOST: {db_host}") +logger.info(f"DB_ENGINE: {db_engine}") +logger.info(f"DB_NAME: {db_name}") +logger.info(f"DB_HOST: {db_host}") DATABASES['default'] = db_config diff --git a/InvenTree/InvenTree/static/css/inventree.css b/InvenTree/InvenTree/static/css/inventree.css index adb5a41ee6..585c0b3825 100644 --- a/InvenTree/InvenTree/static/css/inventree.css +++ b/InvenTree/InvenTree/static/css/inventree.css @@ -640,6 +640,11 @@ z-index: 9999; } +.modal-error { + border: 2px #FCC solid; + background-color: #f5f0f0; +} + .modal-header { border-bottom: 1px solid #ddd; } @@ -730,6 +735,13 @@ padding: 10px; } +.form-panel { + border-radius: 5px; + border: 1px solid #ccc; + padding: 5px; +} + + .modal input { width: 100%; } @@ -1037,6 +1049,11 @@ a.anchor { height: 30px; } +/* Force minimum width of number input fields to show at least ~5 digits */ +input[type='number']{ + min-width: 80px; +} + .search-menu { padding-top: 2rem; } @@ -1044,3 +1061,7 @@ a.anchor { .search-menu .ui-menu-item { margin-top: 0.5rem; } + +.product-card-panel{ + height: 100%; +} diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 24631dc9e5..5fb6960601 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -36,7 +36,7 @@ def schedule_task(taskname, **kwargs): # If this task is already scheduled, don't schedule it again # Instead, update the scheduling parameters if Schedule.objects.filter(func=taskname).exists(): - logger.info(f"Scheduled task '{taskname}' already exists - updating!") + logger.debug(f"Scheduled task '{taskname}' already exists - updating!") Schedule.objects.filter(func=taskname).update(**kwargs) else: @@ -204,6 +204,25 @@ def check_for_updates(): ) +def delete_expired_sessions(): + """ + Remove any expired user sessions from the database + """ + + try: + from django.contrib.sessions.models import Session + + # Delete any sessions that expired more than a day ago + expired = Session.objects.filter(expire_date__lt=timezone.now() - timedelta(days=1)) + + if True or expired.count() > 0: + logger.info(f"Deleting {expired.count()} expired sessions.") + expired.delete() + + except AppRegistryNotReady: + logger.info("Could not perform 'delete_expired_sessions' - App registry not ready") + + def update_exchange_rates(): """ Update currency exchange rates diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 71f6388c68..7d51c6a4cf 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -111,6 +111,7 @@ translated_javascript_urls = [ url(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'), url(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'), url(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'), + url(r'^helpers.js', DynamicJsView.as_view(template_name='js/translated/helpers.js'), name='helpers.js'), url(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'), url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'), url(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'), diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index c25b1abb67..cf9d026166 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -8,36 +8,48 @@ import re import common.models -INVENTREE_SW_VERSION = "0.4.5" +INVENTREE_SW_VERSION = "0.5.0" -INVENTREE_API_VERSION = 9 +INVENTREE_API_VERSION = 12 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about -v9 -> 2021-08-09 +v12 -> 2021-09-07 + - Adds API endpoint to receive stock items against a PurchaseOrder + +v11 -> 2021-08-26 + - Adds "units" field to PartBriefSerializer + - This allows units to be introspected from the "part_detail" field in the StockItem serializer + +v10 -> 2021-08-23 + - Adds "purchase_price_currency" to StockItem serializer + - Adds "purchase_price_string" to StockItem serializer + - Purchase price is now writable for StockItem serializer + +v9 -> 2021-08-09 - Adds "price_string" to part pricing serializers -v8 -> 2021-07-19 +v8 -> 2021-07-19 - Refactors the API interface for SupplierPart and ManufacturerPart models - ManufacturerPart objects can no longer be created via the SupplierPart API endpoint -v7 -> 2021-07-03 +v7 -> 2021-07-03 - Introduced the concept of "API forms" in https://github.com/inventree/InvenTree/pull/1716 - API OPTIONS endpoints provide comprehensive field metedata - Multiple new API endpoints added for database models -v6 -> 2021-06-23 +v6 -> 2021-06-23 - Part and Company images can now be directly uploaded via the REST API -v5 -> 2021-06-21 +v5 -> 2021-06-21 - Adds API interface for manufacturer part parameters -v4 -> 2021-06-01 +v4 -> 2021-06-01 - BOM items can now accept "variant stock" to be assigned against them - Many slight API tweaks were needed to get this to work properly! -v3 -> 2021-05-22: +v3 -> 2021-05-22: - The updated StockItem "history tracking" now uses a different interface """ @@ -58,7 +70,7 @@ def inventreeInstanceTitle(): def inventreeVersion(): """ Returns the InvenTree version string """ - return INVENTREE_SW_VERSION + return INVENTREE_SW_VERSION.lower().strip() def inventreeVersionTuple(version=None): @@ -72,6 +84,30 @@ def inventreeVersionTuple(version=None): return [int(g) for g in match.groups()] +def isInvenTreeDevelopmentVersion(): + """ + Return True if current InvenTree version is a "development" version + """ + return inventreeVersion().endswith('dev') + + +def inventreeDocsVersion(): + """ + Return the version string matching the latest documentation. + + Development -> "latest" + Release -> "major.minor" + + """ + + if isInvenTreeDevelopmentVersion(): + return "latest" + else: + major, minor, patch = inventreeVersionTuple() + + return f"{major}.{minor}" + + def isInvenTreeUpToDate(): """ Test if the InvenTree instance is "up to date" with the latest version. diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 5c0fced884..69e3a7aed0 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -10,7 +10,8 @@ from django.db.models import BooleanField from rest_framework import serializers -from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializerField, UserSerializerBrief +from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer +from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief from stock.serializers import StockItemSerializerBrief from stock.serializers import LocationSerializer @@ -158,7 +159,7 @@ class BuildItemSerializer(InvenTreeModelSerializer): ] -class BuildAttachmentSerializer(InvenTreeModelSerializer): +class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): """ Serializer for a BuildAttachment """ @@ -172,6 +173,7 @@ class BuildAttachmentSerializer(InvenTreeModelSerializer): 'pk', 'build', 'attachment', + 'filename', 'comment', 'upload_date', ] diff --git a/InvenTree/build/templates/build/auto_allocate.html b/InvenTree/build/templates/build/auto_allocate.html index 48d1837ae0..2f2c7bbca7 100644 --- a/InvenTree/build/templates/build/auto_allocate.html +++ b/InvenTree/build/templates/build/auto_allocate.html @@ -6,7 +6,7 @@ {{ block.super }}
- {% blocktrans %}The allocated stock will be installed into the following build output:
{{output}}{% endblocktrans %}
+ {% blocktrans %}The allocated stock will be installed into the following build output:
{{output}}{% endblocktrans %}