diff --git a/.copier-answers.yml b/.copier-answers.yml deleted file mode 100644 index e451e42..0000000 --- a/.copier-answers.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Do NOT update manually; changes here will be overwritten by Copier -_commit: v1.33 -_src_path: https://github.com/OCA/oca-addons-repo-template.git -additional_ruff_rules: [] -ci: GitHub -convert_readme_fragments_to_markdown: true -enable_checklog_odoo: false -generate_requirements_txt: true -github_check_license: true -github_ci_extra_env: {} -github_enable_codecov: true -github_enable_makepot: true -github_enable_stale_action: true -github_enforce_dev_status_compatibility: true -include_wkhtmltopdf: false -odoo_test_flavor: Both -odoo_version: 17.0 -org_name: Odoonix -org_slug: odoonix -rebel_module_groups: [] -repo_description: Education management tools -repo_name: Education, moderl education management system -repo_slug: education -repo_website: https://github.com/odoonix/education -use_pyproject_toml: true -use_ruff: true - diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b35c9bf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +.git/ +.gitignore +sync.log +.env +.pytest_cache/ +.coverage diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index bfd7ac5..0000000 --- a/.editorconfig +++ /dev/null @@ -1,20 +0,0 @@ -# Configuration for known file extensions -[*.{css,js,json,less,md,py,rst,sass,scss,xml,yaml,yml}] -charset = utf-8 -end_of_line = lf -indent_size = 4 -indent_style = space -insert_final_newline = true -trim_trailing_whitespace = true - -[*.{json,yml,yaml,rst,md}] -indent_size = 2 - -# Do not configure editor for libs and autogenerated content -[{*/static/{lib,src/lib}/**,*/static/description/index.html,*/readme/../README.rst}] -charset = unset -end_of_line = unset -indent_size = unset -indent_style = unset -insert_final_newline = false -trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef3deab --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# ---------- اتصال به Odoo ---------- +ODOO_URL=http://localhost:8069 +ODOO_DB=my_odoo_project +ODOO_USERNAME=admin@gmail.com +ODOO_PASSWORD=Mohamad@77 + +# ---------- اتصال به PostgreSQL اپلیکیشن ---------- +APP_DB_HOST=app-db +APP_DB_PORT=5432 +APP_DB_NAME=syncdb +APP_DB_USER=syncuser +APP_DB_PASSWORD=syncpass + +# ---------- تنظیمات عمومی ---------- +LOG_LEVEL=INFO + diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index 0b38203..0000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,188 +0,0 @@ -env: - browser: true - es6: true - -# See https://github.com/OCA/odoo-community.org/issues/37#issuecomment-470686449 -parserOptions: - ecmaVersion: 2022 - -overrides: - - files: - - "**/*.esm.js" - parserOptions: - sourceType: module - -# Globals available in Odoo that shouldn't produce errorings -globals: - _: readonly - $: readonly - fuzzy: readonly - jQuery: readonly - moment: readonly - odoo: readonly - openerp: readonly - owl: readonly - luxon: readonly - -# Styling is handled by Prettier, so we only need to enable AST rules; -# see https://github.com/OCA/maintainer-quality-tools/pull/618#issuecomment-558576890 -rules: - accessor-pairs: warn - array-callback-return: warn - callback-return: warn - capitalized-comments: - - warn - - always - - ignoreConsecutiveComments: true - ignoreInlineComments: true - complexity: - - warn - - 15 - constructor-super: warn - dot-notation: warn - eqeqeq: warn - global-require: warn - handle-callback-err: warn - id-blacklist: warn - id-match: warn - init-declarations: error - max-depth: warn - max-nested-callbacks: warn - max-statements-per-line: warn - no-alert: warn - no-array-constructor: warn - no-caller: warn - no-case-declarations: warn - no-class-assign: warn - no-cond-assign: error - no-const-assign: error - no-constant-condition: warn - no-control-regex: warn - no-debugger: error - no-delete-var: warn - no-div-regex: warn - no-dupe-args: error - no-dupe-class-members: error - no-dupe-keys: error - no-duplicate-case: error - no-duplicate-imports: error - no-else-return: warn - no-empty-character-class: warn - no-empty-function: error - no-empty-pattern: error - no-empty: warn - no-eq-null: error - no-eval: error - no-ex-assign: error - no-extend-native: warn - no-extra-bind: warn - no-extra-boolean-cast: warn - no-extra-label: warn - no-fallthrough: warn - no-func-assign: error - no-global-assign: error - no-implicit-coercion: - - warn - - allow: ["~"] - no-implicit-globals: warn - no-implied-eval: warn - no-inline-comments: warn - no-inner-declarations: warn - no-invalid-regexp: warn - no-irregular-whitespace: warn - no-iterator: warn - no-label-var: warn - no-labels: warn - no-lone-blocks: warn - no-lonely-if: error - no-mixed-requires: error - no-multi-str: warn - no-native-reassign: error - no-negated-condition: warn - no-negated-in-lhs: error - no-new-func: warn - no-new-object: warn - no-new-require: warn - no-new-symbol: warn - no-new-wrappers: warn - no-new: warn - no-obj-calls: warn - no-octal-escape: warn - no-octal: warn - no-param-reassign: warn - no-path-concat: warn - no-process-env: warn - no-process-exit: warn - no-proto: warn - no-prototype-builtins: warn - no-redeclare: warn - no-regex-spaces: warn - no-restricted-globals: warn - no-restricted-imports: warn - no-restricted-modules: warn - no-restricted-syntax: warn - no-return-assign: error - no-script-url: warn - no-self-assign: warn - no-self-compare: warn - no-sequences: warn - no-shadow-restricted-names: warn - no-shadow: warn - no-sparse-arrays: warn - no-sync: warn - no-this-before-super: warn - no-throw-literal: warn - no-undef-init: warn - no-undef: error - no-unmodified-loop-condition: warn - no-unneeded-ternary: error - no-unreachable: error - no-unsafe-finally: error - no-unused-expressions: error - no-unused-labels: error - no-unused-vars: error - no-use-before-define: error - no-useless-call: warn - no-useless-computed-key: warn - no-useless-concat: warn - no-useless-constructor: warn - no-useless-escape: warn - no-useless-rename: warn - no-void: warn - no-with: warn - operator-assignment: [error, always] - prefer-const: warn - radix: warn - require-yield: warn - sort-imports: warn - spaced-comment: [error, always] - strict: [error, function] - use-isnan: error - valid-jsdoc: - - warn - - prefer: - arg: param - argument: param - augments: extends - constructor: class - exception: throws - func: function - method: function - prop: property - return: returns - virtual: abstract - yield: yields - preferType: - array: Array - bool: Boolean - boolean: Boolean - number: Number - object: Object - str: String - string: String - requireParamDescription: false - requireReturn: false - requireReturnDescription: false - requireReturnType: false - valid-typeof: warn - yoda: warn diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index d19e491..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: pre-commit - -on: - pull_request: - branches: - - "17.0*" - push: - branches: - - "17.0" - - "17.0-ocabot-*" - -jobs: - pre-commit: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - name: Get python version - run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV - - uses: actions/cache@v4 - with: - path: ~/.cache/pre-commit - key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} - - name: Install pre-commit - run: pip install pre-commit - - name: Run pre-commit - run: pre-commit run --all-files --show-diff-on-failure --color=always - env: - # Consider valid a PR that changes README fragments but doesn't - # change the README.rst file itself. It's not really a problem - # because the bot will update it anyway after merge. This way, we - # lower the barrier for functional contributors that want to fix the - # readme fragments, while still letting developers get README - # auto-generated (which also helps functionals when using runboat). - # DOCS https://pre-commit.com/#temporarily-disabling-hooks - SKIP: oca-gen-addon-readme - - name: Check that all files generated by pre-commit are in git - run: | - newfiles="$(git ls-files --others --exclude-from=.gitignore)" - if [ "$newfiles" != "" ] ; then - echo "Please check-in the following files:" - echo "$newfiles" - exit 1 - fi diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index fa17fcd..0000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Mark stale issues and pull requests - -on: - schedule: - - cron: "0 12 * * 0" - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - name: Stale PRs and issues policy - uses: actions/stale@v9 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - # General settings. - ascending: true - remove-stale-when-updated: true - # Pull Requests settings. - # 120+30 day stale policy for PRs - # * Except PRs marked as "no stale" - days-before-pr-stale: 120 - days-before-pr-close: 30 - exempt-pr-labels: "no stale" - stale-pr-label: "stale" - stale-pr-message: > - There hasn't been any activity on this pull request in the past 4 months, so - it has been marked as stale and it will be closed automatically if no - further activity occurs in the next 30 days. - - If you want this PR to never become stale, please ask a PSC member to apply - the "no stale" label. - # Issues settings. - # 180+30 day stale policy for open issues - # * Except Issues marked as "no stale" - days-before-issue-stale: 180 - days-before-issue-close: 30 - exempt-issue-labels: "no stale,needs more information" - stale-issue-label: "stale" - stale-issue-message: > - There hasn't been any activity on this issue in the past 6 months, so it has - been marked as stale and it will be closed automatically if no further - activity occurs in the next 30 days. - - If you want this issue to never become stale, please ask a PSC member to - apply the "no stale" label. - - # 15+30 day stale policy for issues pending more information - # * Issues that are pending more information - # * Except Issues marked as "no stale" - - name: Needs more information stale issues policy - uses: actions/stale@v9 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - ascending: true - only-labels: "needs more information" - exempt-issue-labels: "no stale" - days-before-stale: 15 - days-before-close: 30 - days-before-pr-stale: -1 - days-before-pr-close: -1 - remove-stale-when-updated: true - stale-issue-label: "stale" - stale-issue-message: > - This issue needs more information and there hasn't been any activity - recently, so it has been marked as stale and it will be closed automatically - if no further activity occurs in the next 30 days. - - If you think this is a mistake, please ask a PSC member to remove the "needs - more information" label. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 64c25a9..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: tests - -on: - pull_request: - branches: - - "17.0*" - push: - branches: - - "17.0" - - "17.0-ocabot-*" - -jobs: - unreleased-deps: - runs-on: ubuntu-latest - name: Detect unreleased dependencies - steps: - - uses: actions/checkout@v4 - - run: | - for reqfile in requirements.txt test-requirements.txt ; do - if [ -f ${reqfile} ] ; then - result=0 - # reject non-comment lines that contain a / (i.e. URLs, relative paths) - grep "^[^#].*/" ${reqfile} || result=$? - if [ $result -eq 0 ] ; then - echo "Unreleased dependencies found in ${reqfile}." - exit 1 - fi - fi - done - test: - runs-on: ubuntu-22.04 - container: ${{ matrix.container }} - name: ${{ matrix.name }} - strategy: - fail-fast: false - matrix: - include: - - container: ghcr.io/oca/oca-ci/py3.10-odoo17.0:latest - name: test with Odoo - - container: ghcr.io/oca/oca-ci/py3.10-ocb17.0:latest - name: test with OCB - makepot: "true" - services: - postgres: - image: postgres:12.0 - env: - POSTGRES_USER: odoo - POSTGRES_PASSWORD: odoo - POSTGRES_DB: odoo - ports: - - 5432:5432 - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Install addons and dependencies - run: oca_install_addons - - name: Check licenses - run: manifestoo -d . check-licenses - - name: Check development status - run: manifestoo -d . check-dev-status --default-dev-status=Beta - - name: Initialize test db - run: oca_init_test_database - - name: Run tests - run: oca_run_tests - - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - - name: Update .pot files - run: oca_export_and_push_pot https://x-access-token:${{ secrets.GIT_PUSH_TOKEN }}@github.com/${{ github.repository }} - if: ${{ matrix.makepot == 'true' && github.event_name == 'push' && github.repository_owner == 'odoonix' }} diff --git a/.gitignore b/.gitignore index 6ec07a0..fd567d2 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 791ab54..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,138 +0,0 @@ -exclude: | - (?x) - # NOT INSTALLABLE ADDONS - # END NOT INSTALLABLE ADDONS - # Files and folders generated by bots, to avoid loops - ^setup/|/static/description/index\.html$| - # We don't want to mess with tool-generated files - .svg$|/tests/([^/]+/)?cassettes/|^.copier-answers.yml$|^.github/|^eslint.config.cjs|^prettier.config.cjs| - # Maybe reactivate this when all README files include prettier ignore tags? - ^README\.md$| - # Library files can have extraneous formatting (even minimized) - /static/(src/)?lib/| - # Repos using Sphinx to generate docs don't need prettying - ^docs/_templates/.*\.html$| - # Don't bother non-technical authors with formatting issues in docs - readme/.*\.(rst|md)$| - # Ignore build and dist directories in addons - /build/|/dist/| - # Ignore test files in addons - /tests/samples/.*| - # You don't usually want a bot to modify your legal texts - (LICENSE.*|COPYING.*) -default_language_version: - python: python3 - node: "16.17.0" -repos: - - repo: local - hooks: - # These files are most likely copier diff rejection junks; if found, - # review them manually, fix the problem (if needed) and remove them - - id: forbidden-files - name: forbidden files - entry: found forbidden files; remove them - language: fail - files: "\\.rej$" - - id: en-po-files - name: en.po files cannot exist - entry: found a en.po file - language: fail - files: '[a-zA-Z0-9_]*/i18n/en\.po$' - - repo: https://github.com/sbidoul/whool - rev: v1.2 - hooks: - - id: whool-init - - repo: https://github.com/oca/maintainer-tools - rev: f9b919b9868143135a9c9cb03021089cabba8223 - hooks: - # update the NOT INSTALLABLE ADDONS section above - - id: oca-update-pre-commit-excluded-addons - - id: oca-fix-manifest-website - args: ["https://github.com/odoonix/education"] - - id: oca-gen-addon-readme - args: - - --addons-dir=. - - --branch=17.0 - - --org-name=odoonix - - --repo-name=education - - --if-source-changed - - --keep-source-digest - - --convert-fragments-to-markdown - - id: oca-gen-external-dependencies - - repo: https://github.com/OCA/odoo-pre-commit-hooks - rev: v0.0.25 - hooks: - - id: oca-checks-odoo-module - - id: oca-checks-po - args: - - --disable=po-pretty-format - - repo: local - hooks: - - id: prettier - name: prettier (with plugin-xml) - entry: prettier - args: - - --write - - --list-different - - --ignore-unknown - types: [text] - files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$ - language: node - additional_dependencies: - - "prettier@2.7.1" - - "@prettier/plugin-xml@2.2.0" - - repo: local - hooks: - - id: eslint - name: eslint - entry: eslint - args: - - --color - - --fix - verbose: true - types: [javascript] - language: node - additional_dependencies: - - "eslint@8.24.0" - - "eslint-plugin-jsdoc@" - - "globals@" - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 - hooks: - - id: trailing-whitespace - # exclude autogenerated files - exclude: /README\.rst$|\.pot?$ - - id: end-of-file-fixer - # exclude autogenerated files - exclude: /README\.rst$|\.pot?$ - - id: debug-statements - - id: fix-encoding-pragma - args: ["--remove"] - - id: check-case-conflict - - id: check-docstring-first - - id: check-executables-have-shebangs - - id: check-merge-conflict - # exclude files where underlines are not distinguishable from merge conflicts - exclude: /README\.rst$|^docs/.*\.rst$ - - id: check-symlinks - - id: check-xml - - id: mixed-line-ending - args: ["--fix=lf"] - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.3 - hooks: - - id: ruff - args: [--fix, --exit-non-zero-on-fix] - - id: ruff-format - - repo: https://github.com/OCA/pylint-odoo - rev: v9.0.4 - hooks: - - id: pylint_odoo - name: pylint with optional checks - args: - - --rcfile=.pylintrc - - --exit-zero - verbose: true - - id: pylint_odoo - args: - - --rcfile=.pylintrc-mandatory diff --git a/.prettierrc.yml b/.prettierrc.yml deleted file mode 100644 index 5b6d4b3..0000000 --- a/.prettierrc.yml +++ /dev/null @@ -1,8 +0,0 @@ -# Defaults for all prettier-supported languages. -# Prettier will complete this with settings from .editorconfig file. -bracketSpacing: false -printWidth: 88 -proseWrap: always -semi: true -trailingComma: "es5" -xmlWhitespaceSensitivity: "strict" diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index ac1af91..0000000 --- a/.pylintrc +++ /dev/null @@ -1,123 +0,0 @@ - - -[MASTER] -load-plugins=pylint_odoo -score=n - -[ODOOLINT] -readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" -manifest-required-authors=Odoonix -manifest-required-keys=license -manifest-deprecated-keys=description,active -license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3 -valid-odoo-versions=17.0 - -[MESSAGES CONTROL] -disable=all - -# This .pylintrc contains optional AND mandatory checks and is meant to be -# loaded in an IDE to have it check everything, in the hope this will make -# optional checks more visible to contributors who otherwise never look at a -# green travis to see optional checks that failed. -# .pylintrc-mandatory containing only mandatory checks is used the pre-commit -# config as a blocking check. - -enable=anomalous-backslash-in-string, - api-one-deprecated, - api-one-multi-together, - assignment-from-none, - attribute-deprecated, - class-camelcase, - dangerous-default-value, - dangerous-view-replace-wo-priority, - development-status-allowed, - duplicate-id-csv, - duplicate-key, - duplicate-xml-fields, - duplicate-xml-record-id, - eval-referenced, - eval-used, - incoherent-interpreter-exec-perm, - license-allowed, - manifest-author-string, - manifest-deprecated-key, - manifest-required-author, - manifest-required-key, - manifest-version-format, - method-compute, - method-inverse, - method-required-super, - method-search, - openerp-exception-warning, - pointless-statement, - pointless-string-statement, - print-used, - redundant-keyword-arg, - redundant-modulename-xml, - reimported, - relative-import, - return-in-init, - rst-syntax-error, - sql-injection, - too-few-format-args, - translation-field, - translation-required, - unreachable, - use-vim-comment, - wrong-tabs-instead-of-spaces, - xml-syntax-error, - attribute-string-redundant, - character-not-valid-in-resource-link, - consider-merging-classes-inherited, - context-overridden, - create-user-wo-reset-password, - dangerous-filter-wo-user, - dangerous-qweb-replace-wo-priority, - deprecated-data-xml-node, - deprecated-openerp-xml-node, - duplicate-po-message-definition, - except-pass, - file-not-used, - invalid-commit, - manifest-maintainers-list, - missing-newline-extrafiles, - missing-readme, - missing-return, - odoo-addons-relative-import, - old-api7-method-defined, - po-msgstr-variables, - po-syntax-error, - renamed-field-parameter, - resource-not-exist, - str-format-used, - test-folder-imported, - translation-contains-variable, - translation-positional-used, - unnecessary-utf8-coding-comment, - website-manifest-key-not-valid-uri, - xml-attribute-translatable, - xml-deprecated-qweb-directive, - xml-deprecated-tree-attribute, - external-request-timeout, - # messages that do not cause the lint step to fail - consider-merging-classes-inherited, - create-user-wo-reset-password, - dangerous-filter-wo-user, - deprecated-module, - file-not-used, - invalid-commit, - missing-manifest-dependency, - missing-newline-extrafiles, - missing-readme, - no-utf8-coding-comment, - odoo-addons-relative-import, - old-api7-method-defined, - redefined-builtin, - too-complex, - unnecessary-utf8-coding-comment - - -[REPORTS] -msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} -output-format=colorized -reports=no diff --git a/.pylintrc-mandatory b/.pylintrc-mandatory deleted file mode 100644 index e886309..0000000 --- a/.pylintrc-mandatory +++ /dev/null @@ -1,98 +0,0 @@ - -[MASTER] -load-plugins=pylint_odoo -score=n - -[ODOOLINT] -readme-template-url="https://github.com/OCA/maintainer-tools/blob/master/template/module/README.rst" -manifest-required-authors=Odoonix -manifest-required-keys=license -manifest-deprecated-keys=description,active -license-allowed=AGPL-3,GPL-2,GPL-2 or any later version,GPL-3,GPL-3 or any later version,LGPL-3 -valid-odoo-versions=17.0 - -[MESSAGES CONTROL] -disable=all - -enable=anomalous-backslash-in-string, - api-one-deprecated, - api-one-multi-together, - assignment-from-none, - attribute-deprecated, - class-camelcase, - dangerous-default-value, - dangerous-view-replace-wo-priority, - development-status-allowed, - duplicate-id-csv, - duplicate-key, - duplicate-xml-fields, - duplicate-xml-record-id, - eval-referenced, - eval-used, - incoherent-interpreter-exec-perm, - license-allowed, - manifest-author-string, - manifest-deprecated-key, - manifest-required-author, - manifest-required-key, - manifest-version-format, - method-compute, - method-inverse, - method-required-super, - method-search, - openerp-exception-warning, - pointless-statement, - pointless-string-statement, - print-used, - redundant-keyword-arg, - redundant-modulename-xml, - reimported, - relative-import, - return-in-init, - rst-syntax-error, - sql-injection, - too-few-format-args, - translation-field, - translation-required, - unreachable, - use-vim-comment, - wrong-tabs-instead-of-spaces, - xml-syntax-error, - attribute-string-redundant, - character-not-valid-in-resource-link, - consider-merging-classes-inherited, - context-overridden, - create-user-wo-reset-password, - dangerous-filter-wo-user, - dangerous-qweb-replace-wo-priority, - deprecated-data-xml-node, - deprecated-openerp-xml-node, - duplicate-po-message-definition, - except-pass, - file-not-used, - invalid-commit, - manifest-maintainers-list, - missing-newline-extrafiles, - missing-readme, - missing-return, - odoo-addons-relative-import, - old-api7-method-defined, - po-msgstr-variables, - po-syntax-error, - renamed-field-parameter, - resource-not-exist, - str-format-used, - test-folder-imported, - translation-contains-variable, - translation-positional-used, - unnecessary-utf8-coding-comment, - website-manifest-key-not-valid-uri, - xml-attribute-translatable, - xml-deprecated-qweb-directive, - xml-deprecated-tree-attribute, - external-request-timeout - -[REPORTS] -msg-template={path}:{line}: [{msg_id}({symbol}), {obj}] {msg} -output-format=colorized -reports=no diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 8e8eccb..0000000 --- a/.ruff.toml +++ /dev/null @@ -1,31 +0,0 @@ - -target-version = "py310" -fix = true - -[lint] -extend-select = [ - "B", - "C90", - "E501", # line too long (default 88) - "I", # isort - "UP", # pyupgrade -] -extend-safe-fixes = ["UP008"] -exclude = ["setup/*"] - -[format] -exclude = ["setup/*"] - -[per-file-ignores] -"__init__.py" = ["F401", "I001"] # ignore unused and unsorted imports in __init__.py -"__manifest__.py" = ["B018"] # useless expression - -[isort] -section-order = ["future", "standard-library", "third-party", "odoo", "odoo-addons", "first-party", "local-folder"] - -[isort.sections] -"odoo" = ["odoo"] -"odoo-addons" = ["odoo.addons"] - -[mccabe] -max-complexity = 16 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db0740a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +# اول فقط requirements رو کپی می‌کنیم که اگه کد عوض شد ولی وابستگی‌ها نه، +# Docker از cache استفاده کنه و rebuild سریع‌تر بشه. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# پیش‌فرض: Migration رو اجرا کن، بعد کانتینر رو زنده نگه دار (به‌جای اینکه +# بلافاصله بسته بشه). این باعث میشه بشه با «docker compose exec app ...» +# دستورات رو روی یه کانتینر پایدار و از‌قبل‌متصل‌به‌شبکه اجرا کرد، به‌جای +# اینکه هر بار «docker compose run» یه کانتینر تازه بسازه (که رو بعضی +# سیستم‌ها -- مخصوصاً Windows/WSL2 -- چند ثانیه طول می‌کشه تا شبکه‌اش +# کاملاً پایدار بشه و باعث خطای گذرای Connection Refused میشه). +CMD ["sh", "-c", "alembic upgrade head && tail -f /dev/null"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..82c89e2 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +COPY requirements-dev.txt . + +RUN pip install --no-cache-dir -r requirements-dev.txt + +COPY . . + +CMD ["sh", "-c", "alembic upgrade head && tail -f /dev/null"] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index be3f7b2..0000000 --- a/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/README.md b/README.md index 080d724..a0f66a6 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,71 @@ +# Odoo ⇄ PostgreSQL Sync - -[![Pre-commit Status](https://github.com/odoonix/education/actions/workflows/pre-commit.yml/badge.svg?branch=17.0)](https://github.com/odoonix/education/actions/workflows/pre-commit.yml?query=branch%3A17.0) -[![Build Status](https://github.com/odoonix/education/actions/workflows/test.yml/badge.svg?branch=17.0)](https://github.com/odoonix/education/actions/workflows/test.yml?query=branch%3A17.0) -[![codecov](https://codecov.io/gh/odoonix/education/branch/17.0/graph/badge.svg)](https://codecov.io/gh/odoonix/education) - +پروژه‌ی همگام‌سازی داده (Contacts, Products, Sale Orders, Sale Order Lines) +از Odoo به PostgreSQL، با معماری لایه‌ای (Layered Architecture)، طراحی +Idempotent، مدیریت خطای پیشرفته، Retry Mechanism، Incremental Sync و +Graceful Shutdown. - +## معماری -# Education, moderl education management system +``` +Odoo (XML-RPC) → OdooClient → Mapper → Service Layer → Repository → PostgreSQL +``` -Education management tools +جزئیات کامل معماری، تصمیمات طراحی و محدودیت‌های شناخته‌شده: +📄 [`docs/TECHNICAL_DOCUMENTATION.md`](docs/TECHNICAL_DOCUMENTATION.md) - +## شروع سریع - +راهنمای کامل قدم‌به‌قدم (هم روش Docker، هم روش دستی): +📄 [`docs/USER_DOCUMENTATION.md`](docs/USER_DOCUMENTATION.md) -[//]: # (addons) +خلاصه‌ی فوق‌فشرده (روش Docker): -This part will be replaced when running the oca-gen-addons-table script from OCA/maintainer-tools. +```bash +docker compose up -d # بالا آوردن همه‌چیز +# ... ساخت دیتابیس Odoo از طریق مرورگر (جزئیات تو USER_DOCUMENTATION) ... +cp .env.example .env # و پر کردن پسورد +docker compose run --rm app python odoo/init_data.py # داده‌ی تستی +docker compose run --rm app python -m src.main # اجرای Sync +docker compose run --rm app pytest -v # اجرای تست‌ها +``` -[//]: # (end addons) +## ساختار پروژه - +``` +Dockerfile تصویر Docker برنامه‌ی Backend +docker-compose.yml Odoo + دیتابیس‌ها + Backend +odoo/init_data.py ساخت داده‌ی تستی در Odoo (idempotent) +src/ + odoo_client/ اتصال به Odoo (XML-RPC) + Retry + Incremental + domain/ Domain Models (dataclass تمیز) + mappers/ تبدیل داده‌ی خام Odoo به Domain Model + repositories/ ذخیره در PostgreSQL با Upsert idempotent + services/ Orchestration + مدیریت خطا + Logging + Graceful Shutdown + db/ مدل‌های SQLAlchemy + Session + core/ Retry decorator + Logging config + main.py نقطه‌ی ورود اصلی +migrations/ Alembic migrations +tests/unit/ Unit Testها (pytest + mock، بدون نیاز به Odoo واقعی) +scripts/ اسکریپت‌های کمکی برای تست دستی هر لایه +docs/ مستندات فنی و کاربری +``` -## Licenses +## تکنولوژی‌ها -This repository is licensed under [AGPL-3.0](LICENSE). +Python · SQLAlchemy · Alembic · PostgreSQL · Docker · Odoo (XML-RPC) · pytest -However, each module can have a totally different license, as long as they adhere to Odoonix -policy. Consult each module's `__manifest__.py` file, which contains a `license` key -that explains its license. +## وضعیت پروژه ----- - +- [x] Docker Compose (Odoo + PostgreSQL + Backend، همه با `docker compose up`) +- [x] داده‌ی تستی در Odoo (اسکریپت idempotent) +- [x] مدل‌های SQLAlchemy + Alembic Migration +- [x] Client اتصال به Odoo با Pagination +- [x] Domain Models + Mapper (با Abstract Base Class) +- [x] Repository Layer (Upsert idempotent) +- [x] Service Layer (Orchestration + مدیریت خطا با SAVEPOINT) +- [x] Retry Mechanism (Exponential Backoff) + Structured Logging +- [x] Incremental Sync (بر پایه‌ی `write_date`) +- [x] Graceful Shutdown (مدیریت SIGTERM/Ctrl+C) +- [x] Unit Tests (۲۳ تست، Mapper/Repository/Service/Retry) +- [x] مستندات فنی و کاربری diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..08f1d9f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/bookland/README.rst b/bookland/README.rst deleted file mode 100644 index 62d7ade..0000000 --- a/bookland/README.rst +++ /dev/null @@ -1,72 +0,0 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - -============================================ -Book land a modern library management system -============================================ - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:6e9bd71f9df246f05e4bc098c4bf905387b718570abfb0c2ca222227f560fa69 - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png - :target: https://odoo-community.org/page/development-status - :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png - :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html - :alt: License: LGPL-3 -.. |badge3| image:: https://img.shields.io/badge/github-odoonix%2Feducation-lightgray.png?logo=github - :target: https://github.com/odoonix/education/tree/17.0/bookland - :alt: odoonix/education - -|badge1| |badge2| |badge3| - -این یک اپلیکشن برای مدیریت کتابخانه‌های دانشگاه است. - -در این نرم افزار شما امکانات متفاوتی خواهی داشت. - -**Table of contents** - -.. contents:: - :local: - -Changelog -========= - -نسخه ۱.۰.۰ -========== - -در این نسخه ساختار اولیه نرم افزار ایجاد شده و امکانات زیر ایجاد شده -است. - -- TODO: - -Bug Tracker -=========== - -Bugs are tracked on `GitHub Issues `_. -In case of trouble, please check there if your issue has already been reported. -If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. - -Do not contact contributors directly about support or help with technical issues. - -Credits -======= - -Authors -------- - -* Odoonix - -Maintainers ------------ - -This module is part of the `odoonix/education `_ project on GitHub. - -You are welcome to contribute. diff --git a/bookland/__init__.py b/bookland/__init__.py deleted file mode 100644 index 38718f0..0000000 --- a/bookland/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import models -from . import controllers \ No newline at end of file diff --git a/bookland/__manifest__.py b/bookland/__manifest__.py deleted file mode 100644 index 8b724fc..0000000 --- a/bookland/__manifest__.py +++ /dev/null @@ -1,19 +0,0 @@ -# pylint: disable=W0104 - -{ - "name": "Book land a modern library management system", - "website": "https://github.com/odoonix/education", - "author": "Odoonix", - "license": "LGPL-3", - "depends":["website"], - "data": [ - # Security - "security/bookland_security.xml", - "security/ir.model.access.csv", - # Views - "views/books_template.xml", - "views/book_views.xml", - # Datas - "datas/menu_website.xml", - ], -} diff --git a/bookland/controllers/__init__.py b/bookland/controllers/__init__.py deleted file mode 100644 index deec4a8..0000000 --- a/bookland/controllers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import main \ No newline at end of file diff --git a/bookland/controllers/main.py b/bookland/controllers/main.py deleted file mode 100644 index ec53c48..0000000 --- a/bookland/controllers/main.py +++ /dev/null @@ -1,12 +0,0 @@ -from odoo import http, _ -from odoo.http import request - -class BookController(http.Controller): - - @http.route('/books', type='http', auth="user", website=True) - def books_details(self): - book_ids =request.env['bookland.book'].sudo().search([]) - values = { - 'books': book_ids, - } - return request.render('bookland.books_details_template', values) \ No newline at end of file diff --git a/bookland/datas/menu_website.xml b/bookland/datas/menu_website.xml deleted file mode 100644 index 8818611..0000000 --- a/bookland/datas/menu_website.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - Books - /books - - 10 - - \ No newline at end of file diff --git a/bookland/docs/data-model-class-diagram.uxf b/bookland/docs/data-model-class-diagram.uxf deleted file mode 100644 index b56f3eb..0000000 --- a/bookland/docs/data-model-class-diagram.uxf +++ /dev/null @@ -1,58 +0,0 @@ -10UMLClass5051025090Book --- -+ title -+ price -+ ?? - ---UMLClass65048025090Partner -<<res.partner>> -UMLClass69066025090Language -<<res.lang>> -Relation290560420180book language - -lt=- - -r1=lang_id -m1=1..1 - -// r2=book_ids -m2=0..n - -400;160;10;10Relation290520380120Wirte -<<book_partner_auther_rel>> - -lt=- - -r1=author_ids -m1=1..n - -r2=book_ids -m2=0..n - -360;20;10;20UMLClass350260250110Loan -<<book.loan>> --- -+ date -+ expire_date -+ note ---Relation590310230190get a loan - -lt=- - -r1=loaner_id -m1=1..1 - -r2=loan_ids -m2=0..n - -170;170;10;10Relation140310230220give loan - -lt=- - -r1=book_id -m1=1..1 - -r2=loan_ids -m2=0..n - -10;200;210;10 \ No newline at end of file diff --git a/bookland/models/__init__.py b/bookland/models/__init__.py deleted file mode 100644 index 4696309..0000000 --- a/bookland/models/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import book -from . import book_tag diff --git a/bookland/models/book.py b/bookland/models/book.py deleted file mode 100644 index fa2f90b..0000000 --- a/bookland/models/book.py +++ /dev/null @@ -1,124 +0,0 @@ -import random - -from attr import field -import logging - -from odoo.exceptions import UserError, ValidationError -from odoo import fields, models, api, Command -from datetime import date - -_logger = logging.getLogger(__name__) - -class BooklandBook(models.Model): - # Model Attributes - _name = "bookland.book" - _inherit = ['image.mixin'] - _description = "Book" - - # Fields - name = fields.Char( - string="Book Title", - help="Store the title of the book", - translate=True, - required=True, - index=True, - size=512, - ) - - active = fields.Boolean(default="True") - - display_name = fields.Char( - string="Book Display Title", - translate=True, - ) - description = fields.Html( - # string="Description" - required=False, - translate=True, - index=False, - ) - publish_date = fields.Date(string="Publish") - - # time_to_market_date = { - # "type": "date", - # "name": "present_date", - # "note": "This is when the book is present to market" - # } - time_to_market_date = fields.Date(required=False) - - price = fields.Float( - string="Book Price", - ) - - age_in_days = fields.Integer( - string="Days Since Publish", - compute="_compute_age_in_days", # the function computed - store=True, - ) - - tag_ids = fields.Many2many("bookland.book.tag") - - @api.onchange("publish_date") - def _compute_age_in_days(self): - for record in self: - if record.publish_date: - delta = date.today() - record.publish_date - record.age_in_days = delta.days - else: - record.age_in_days = 0 - - @api.model - def action_create_random_book(self, *args, **kwargs): - self.env["bookland.book"].create({ - "name": "Name" + str(random.randint(0, 1500)), - "description": "", - "tag_ids": [Command.create({ - "name": "tag1" - })] - }) - - @api.model - def action_test_erro(self, *args, **kwargs): - # self.ensore_once() - raise UserError("There is something bad") - - @api.onchange('name') - def _onchange_name(self): - for record in self: - record.update({ - "display_name": f"Book {record.name}" - }) - # self.unlink() - - - # def _get_bookss_name(self, parents): - # names = parents.mapped('name') - # return names - - - def create(self,vals_list): - res = super().create(vals_list) - - grouped_result = self.read_group( - [], #domain - ['name', 'price:sum'], #fields - ['name'] #group_by - ) - - - # Search - # all_record = self.env['bookland.book'].search([],order='publish_date asc') - # names=self._get_bookss_name(all_record) - # record12 = self.env['bookland.book'].browse(1) - # for record in all_record: - # _logger.info(record.name) - # Logs vals_list - for val in vals_list: - _logger.info(val) - return res - - def write(self,vals_list): - res = super().write(vals_list) - for val in vals_list: - _logger.info(val) - return res \ No newline at end of file diff --git a/bookland/models/book_tag.py b/bookland/models/book_tag.py deleted file mode 100644 index d2f2d29..0000000 --- a/bookland/models/book_tag.py +++ /dev/null @@ -1,21 +0,0 @@ -import random - -from odoo.exceptions import UserError, ValidationError -from odoo import fields, models, api -from datetime import date - - -class BooklandBookTag(models.Model): - # Model Attributes - _name = "bookland.book.tag" - _description = "Book Tag" - - # Fields - name = fields.Char( - string="Book Title", - help="Store the title of the book", - translate=True, - required=True, - index=True, - size=512, - ) diff --git a/bookland/pyproject.toml b/bookland/pyproject.toml deleted file mode 100644 index 4231d0c..0000000 --- a/bookland/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["whool"] -build-backend = "whool.buildapi" diff --git a/bookland/readme/DESCRIPTION.md b/bookland/readme/DESCRIPTION.md deleted file mode 100644 index 33ffcb1..0000000 --- a/bookland/readme/DESCRIPTION.md +++ /dev/null @@ -1,3 +0,0 @@ -این یک اپلیکشن برای مدیریت کتابخانه‌های دانشگاه است. - -در این نرم افزار شما امکانات متفاوتی خواهی داشت. \ No newline at end of file diff --git a/bookland/readme/HISTORY.md b/bookland/readme/HISTORY.md deleted file mode 100644 index 8c4627e..0000000 --- a/bookland/readme/HISTORY.md +++ /dev/null @@ -1,7 +0,0 @@ - - -# نسخه ۱.۰.۰ - -در این نسخه ساختار اولیه نرم افزار ایجاد شده و امکانات زیر ایجاد شده است. - -* TODO: \ No newline at end of file diff --git a/bookland/security/bookland_security.xml b/bookland/security/bookland_security.xml deleted file mode 100644 index 10a00b6..0000000 --- a/bookland/security/bookland_security.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - Bookland Management - Manage books and related data. - 10 - - - - - Bookland Manager - - Users in this group can manage all books. - - - - Bookland User - - Users in this group can view and edit their own books. - - - - - Bookland: User can see own books - - - [('create_uid', '=', user.id)] - - - diff --git a/bookland/security/ir.model.access.csv b/bookland/security/ir.model.access.csv deleted file mode 100644 index 820992a..0000000 --- a/bookland/security/ir.model.access.csv +++ /dev/null @@ -1,4 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -book_global_access,Book Global Access,model_bookland_book,bookland.group_bookland_user,1,1,1,0 -book_base_users_access,Book Global Access,model_bookland_book,base.group_system,1,1,1,1 -bookland_book_tag_users_access,bookland_book_tag Access,model_bookland_book_tag,base.group_user,1,1,1,1 diff --git a/bookland/static/description/index.html b/bookland/static/description/index.html deleted file mode 100644 index aacd0df..0000000 --- a/bookland/static/description/index.html +++ /dev/null @@ -1,429 +0,0 @@ - - - - - -README.rst - - - -
- - - -Odoo Community Association - -
-

Book land a modern library management system

- -

Beta License: LGPL-3 odoonix/education

-

این یک اپلیکشن برای مدیریت کتابخانه‌های دانشگاه است.

-

در این نرم افزار شما امکانات متفاوتی خواهی داشت.

-

Table of contents

- - -
-

نسخه ۱.۰.۰

-

در این نسخه ساختار اولیه نرم افزار ایجاد شده و امکانات زیر ایجاد شده -است.

-
    -
  • TODO:
  • -
-
-
-

Bug Tracker

-

Bugs are tracked on GitHub Issues. -In case of trouble, please check there if your issue has already been reported. -If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

-

Do not contact contributors directly about support or help with technical issues.

-
-
-

Credits

-
-

Authors

-
    -
  • Odoonix
  • -
-
-
-

Maintainers

-

This module is part of the odoonix/education project on GitHub.

-

You are welcome to contribute.

-
-
-
-
- - diff --git a/bookland/views/book_views.xml b/bookland/views/book_views.xml deleted file mode 100644 index dc81963..0000000 --- a/bookland/views/book_views.xml +++ /dev/null @@ -1,87 +0,0 @@ - - - - - Book Form - bookland.book - -
- - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - - Book List - bookland.book - - - - - - - - - - - book.search - bookland.book - - - - - - - - - - - - - - - - - Book List - bookland.book - tree,form - - - - - - Bookland - - - - - Books - - - - - -
diff --git a/bookland/views/books_template.xml b/bookland/views/books_template.xml deleted file mode 100644 index 0fa75e3..0000000 --- a/bookland/views/books_template.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - \ No newline at end of file diff --git a/bookland_account/README.rst b/bookland_account/README.rst deleted file mode 100644 index d76fd5f..0000000 --- a/bookland_account/README.rst +++ /dev/null @@ -1,72 +0,0 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - -============================ -Book land accounting feature -============================ - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:aea4cf208e370fcf4ea7f54b5f62c2319c1c78e6263fdafba294eed37a30cd2e - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png - :target: https://odoo-community.org/page/development-status - :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png - :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html - :alt: License: LGPL-3 -.. |badge3| image:: https://img.shields.io/badge/github-odoonix%2Feducation-lightgray.png?logo=github - :target: https://github.com/odoonix/education/tree/17.0/bookland_account - :alt: odoonix/education - -|badge1| |badge2| |badge3| - -این ماژول امکانات حسابداری را اضافه می‌کند - -**Table of contents** - -.. contents:: - :local: - -Known issues / Roadmap -====================== - -V2.0.0 -====== - -- connect to invoice - -Changelog -========= - - - -Bug Tracker -=========== - -Bugs are tracked on `GitHub Issues `_. -In case of trouble, please check there if your issue has already been reported. -If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. - -Do not contact contributors directly about support or help with technical issues. - -Credits -======= - -Authors -------- - -* Odoonix - -Maintainers ------------ - -This module is part of the `odoonix/education `_ project on GitHub. - -You are welcome to contribute. diff --git a/bookland_account/__manifest__.py b/bookland_account/__manifest__.py deleted file mode 100644 index f2fa465..0000000 --- a/bookland_account/__manifest__.py +++ /dev/null @@ -1,7 +0,0 @@ -# pylint: disable=W0104 -{ - "name": "Book land accounting feature", - "website": "https://github.com/odoonix/education", - "author": "Odoonix", - "license": "LGPL-3", -} diff --git a/bookland_account/pyproject.toml b/bookland_account/pyproject.toml deleted file mode 100644 index 4231d0c..0000000 --- a/bookland_account/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["whool"] -build-backend = "whool.buildapi" diff --git a/bookland_account/readme/DESCRIPTION.md b/bookland_account/readme/DESCRIPTION.md deleted file mode 100644 index eae8616..0000000 --- a/bookland_account/readme/DESCRIPTION.md +++ /dev/null @@ -1 +0,0 @@ -این ماژول امکانات حسابداری را اضافه می‌کند \ No newline at end of file diff --git a/bookland_account/readme/ROADMAP.md b/bookland_account/readme/ROADMAP.md deleted file mode 100644 index 4f44150..0000000 --- a/bookland_account/readme/ROADMAP.md +++ /dev/null @@ -1,5 +0,0 @@ - - -# V2.0.0 - -- connect to invoice \ No newline at end of file diff --git a/bookland_account/static/description/index.html b/bookland_account/static/description/index.html deleted file mode 100644 index 4ee816a..0000000 --- a/bookland_account/static/description/index.html +++ /dev/null @@ -1,430 +0,0 @@ - - - - - -README.rst - - - -
- - - -Odoo Community Association - -
-

Book land accounting feature

- -

Beta License: LGPL-3 odoonix/education

-

این ماژول امکانات حسابداری را اضافه می‌کند

-

Table of contents

- - -
-

V2.0.0

-
    -
  • connect to invoice
  • -
-
- -
-

Bug Tracker

-

Bugs are tracked on GitHub Issues. -In case of trouble, please check there if your issue has already been reported. -If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

-

Do not contact contributors directly about support or help with technical issues.

-
-
-

Credits

-
-

Authors

-
    -
  • Odoonix
  • -
-
-
-

Maintainers

-

This module is part of the odoonix/education project on GitHub.

-

You are welcome to contribute.

-
-
-
-
- - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ef2bf5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,95 @@ +services: + + # ---------- دیتابیس اختصاصی Odoo ---------- + odoo-db: + image: postgres:15 + container_name: odoo-db + environment: + POSTGRES_DB: postgres + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + volumes: + - odoo_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U odoo"] + interval: 5s + timeout: 5s + retries: 10 + + # ---------- سرور Odoo ---------- + odoo: + image: odoo:17 + container_name: odoo + depends_on: + odoo-db: + condition: service_healthy + ports: + - "8069:8069" + environment: + HOST: odoo-db + USER: odoo + PASSWORD: odoo + volumes: + - odoo_web_data:/var/lib/odoo + - ./odoo/addons:/mnt/extra-addons + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8069/web/login"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + # ---------- دیتابیس اپلیکیشن Backend خودمون ---------- + app-db: + image: postgres:15 + container_name: app-db + environment: + POSTGRES_DB: syncdb + POSTGRES_USER: syncuser + POSTGRES_PASSWORD: syncpass + ports: + - "5433:5432" + volumes: + - app_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U syncuser -d syncdb"] + interval: 5s + timeout: 5s + retries: 10 + + # ---------- برنامه‌ی Backend پایتون ---------- + app: + build: + context: . + dockerfile: Dockerfile.dev + + container_name: sync-app + + depends_on: + odoo: + condition: service_healthy + app-db: + condition: service_healthy + + environment: + ODOO_URL: http://odoo:8069 + ODOO_DB: ${ODOO_DB:-my_odoo_project} + ODOO_USERNAME: ${ODOO_USERNAME:-admin@gmail.com} + ODOO_PASSWORD: ${ODOO_PASSWORD:-Mohamad@77} + + APP_DB_HOST: app-db + APP_DB_PORT: "5432" + APP_DB_NAME: syncdb + APP_DB_USER: syncuser + APP_DB_PASSWORD: syncpass + + LOG_LEVEL: INFO + # کانتینر بعد از اجرای Migration زنده می‌مونه (طبق CMD تو Dockerfile). + # برای اجرای واقعی Sync، بعد از ساخت دستی دیتابیس Odoo (طبق + # USER_DOCUMENTATION) این دستور رو بزن: + # docker compose exec app python -m src.main + +volumes: + odoo_db_data: + odoo_web_data: + app_db_data: diff --git a/docs/TECHNICAL_DOCUMENTATION.md b/docs/TECHNICAL_DOCUMENTATION.md new file mode 100644 index 0000000..8e5aebf --- /dev/null +++ b/docs/TECHNICAL_DOCUMENTATION.md @@ -0,0 +1,137 @@ +# Technical Documentation + +## معماری کلی + +``` +Odoo (XML-RPC) + │ + ▼ +OdooClient (src/odoo_client/client.py) + │ raw dict + ▼ +Mapper (src/mappers/) + │ Domain Model (dataclass تمیز) + ▼ +Service Layer (src/services/) + │ orchestration + error handling + logging + ▼ +Repository (src/repositories/) + │ Upsert (Idempotent) + ▼ +PostgreSQL (src/db/models.py — SQLAlchemy ORM) +``` + +هر پیکان یک لایه‌ی مجزاست که فقط با لایه‌ی مجاور خودش کار می‌کنه. این +Layered Architecture باعث میشه: +- تغییر یک لایه (مثلاً عوض‌کردن Odoo با یه سیستم دیگه) بقیه رو خراب نکنه +- هر لایه جدا و مستقل تست بشه (`tests/unit/`) + +## تصمیمات مهم معماری + +### ۱. چرا Repository Pattern؟ +Business Logic (Service Layer) نباید بدونه SQLAlchemy چطور کار می‌کنه. +Repositoryها این جزئیات رو مخفی می‌کنن و یک API ساده (`upsert`, +`get_by_odoo_id`) در اختیار Service Layer می‌ذارن. + +### ۲. چطور Idempotency پیاده‌سازی شده؟ +هر جدولی که از Odoo میاد یک ستون `odoo_id` با `UNIQUE constraint` داره. +`BaseRepository.upsert()` قبل از insert، با `odoo_id` جستجو می‌کنه: +- پیدا شد → فیلدها رو آپدیت می‌کنه +- پیدا نشد → رکورد جدید می‌سازه + + + +پس اجرای مکرر `python -m src.main` هیچ‌وقت رکورد تکراری نمی‌سازه. + + + +### ۳. مدیریت خطا بدون متوقف‌کردن کل فرآیند +از تکنیک **SAVEPOINT** (`session.begin_nested()`) استفاده شده. هر رکورد +داخل یک SAVEPOINT پردازش میشه؛ اگه اون رکورد خطا بده، فقط تغییرات همون +رکورد rollback میشه، نه کل Transaction. این یعنی رکورد ۱ و ۲ موفق می‌مونن +حتی اگه رکورد ۳ خطا بده و رکورد ۴ و ۵ هم پردازش بشن — دقیقاً طبق سناریوی +درخواستی. + +### ۴. چرا Retry فقط روی خطاهای شبکه‌ای اعمال شده، نه همه‌چیز؟ +اگه پسورد Odoo غلط باشه، retry کردن ۳ باره فایده‌ای نداره (نتیجه همیشه یکیه). +`src/core/retry.py` یک لیست `NON_RETRYABLE_EXCEPTIONS` داره +(`ValueError`, `TypeError`, `KeyError`, `AttributeError`, `IndexError`, +`KeyboardInterrupt`, `SystemExit`) که این خطاها **هیچ‌وقت** retry نمیشن — +حتی اگه صراحتاً تو پارامتر `exceptions` هم پاس داده بشن — چون این‌ها معمولاً +یعنی باگ تو کد یا داده، نه یه مشکل موقتی شبکه. Retry فقط برای خطاهای واقعاً +گذرا مثل `ConnectionError`, `OSError`, `TimeoutError` فعال شده، با +Exponential Backoff (و یک سقف `max_delay` که تأخیر بی‌نهایت زیاد نشه). + +### ۵. Dependency Injection +`Session` و `OdooClient` همیشه از بیرون به Repository/Service تزریق میشن +(نه اینکه خودشون بسازنش). این باعث میشه تو تست‌ها بتونیم `OdooClient` رو با +`unittest.mock.MagicMock` جایگزین کنیم و به Odoo واقعی وصل نشیم. + +## طراحی دیتابیس + +جدول‌های اصلی: `contacts`, `products`, `sale_orders`, `sale_order_lines` + +روابط: +- `sale_orders.customer_id` → FK به `contacts.id` +- `sale_order_lines.sale_order_id` → FK به `sale_orders.id` +- `sale_order_lines.product_id` → FK به `products.id` + +جدول‌های Logging: `sync_runs` (خلاصه‌ی هر اجرای Sync)، `sync_logs` +(جزئیات هر خطا، وابسته به `sync_runs` با FK). + +هر ۴ جدول اصلی یک ستون `odoo_id` (UNIQUE) دارن که کلید Idempotency هستن. + +### ۶. چطور کل پروژه با `docker compose up` بالا میاد؟ +`Dockerfile` سرویس `app` رو می‌سازه که به Backend پایتون تبدیلش می‌کنه. +پیش‌فرض این سرویس، فقط `alembic upgrade head` (ساخت جدول‌ها) رو اجرا +می‌کنه — چون ساخت دیتابیس Odoo (طبق سناریوی آزمون) یک قدم دستیه که باید +از طریق مرورگر انجام بشه، و نمی‌تونیم مطمئن باشیم قبل از اولین +`docker compose up` انجام شده یا نه. بعد از انجام اون قدم دستی، Sync واقعی +با این دستور اجرا میشه: +``` +docker compose run --rm app python -m src.main +``` +تنظیمات شبکه‌ای (`ODOO_URL=http://odoo:8069`, `APP_DB_HOST=app-db`) مستقیم +تو `docker-compose.yml` (بخش `environment` سرویس `app`) ست شدن، چون این +کانتینر داخل شبکه‌ی داکر اجرا میشه و باید از اسم سرویس‌ها استفاده کنه، نه +`localhost`. مقادیر حساس (پسورد و ...) از فایل `.env` (که هیچ‌وقت وارد +Docker Image نمیشه، طبق `.dockerignore`) خونده و به Container تزریق میشه. + +### ۷. Incremental Sync +`BaseSyncService.get_last_successful_sync_time()` زمان شروع آخرین اجرای +موفق همون `operation_type` رو از جدول `sync_runs` پیدا می‌کنه. Serviceهای +فرزند این زمان رو به‌عنوان `since` به `OdooClient` پاس میدن، که فیلتر +`write_date >= since` رو به کوئری Odoo اضافه می‌کنه. یعنی از دومین اجرا به +بعد، فقط رکوردهایی که واقعاً تغییر کردن خونده میشن، نه کل جدول. + +### ۸. Graceful Shutdown +وقتی `docker stop` یا Ctrl+C بزنی، سیگنال SIGTERM/SIGINT میاد. تو `main.py` +این سیگنال به `KeyboardInterrupt` تبدیل میشه. `BaseSyncService.run()` این +Exception رو جدا از خطاهای معمولی مدیریت می‌کنه: رکورد فعلی رو نیمه‌کاره +رها نمی‌کنه (به لطف SAVEPOINT، رکوردهای قبلی که موفق بودن سالم می‌مونن)، +وضعیت رو با `status="cancelled"` تو `sync_runs` ثبت می‌کنه، و بعد خارج +میشه — بدون قفل‌شدن یا خرابی داده. + +## محدودیت‌های شناخته‌شده + +- **Sync یک‌طرفه است**: فقط Odoo → PostgreSQL. تغییرات دستی مستقیم تو + PostgreSQL به Odoo برنمی‌گرده (خارج از Scope این پروژه بود). +- **Delete پشتیبانی نمیشه**: اگه رکوردی تو Odoo حذف بشه، این Sync خودش + رو تو PostgreSQL حذف نمی‌کنه (فقط Create/Update). اضافه‌کردنش امکان‌پذیره + ولی نیاز به یک استراتژی جدا داره (مثلاً soft-delete بر اساس مقایسه‌ی + لیست کامل odoo_idها). +- **ساخت دیتابیس Odoo دستیه**: طبق سناریوی آزمون، این کار (ساخت دیتابیس از + طریق مرورگر، چون Odoo از قبل هیچ دیتابیسی نداره) یک قدم دستیه که با + `docker compose up` به‌تنهایی خودکار نمیشه. به همین دلیل سرویس `app` تو + Docker پیش‌فرض فقط Migration رو اجرا می‌کنه، نه Sync کامل رو (جزئیات تو + User Documentation). +- **Concurrency**: اگه دو نمونه از این برنامه هم‌زمان اجرا بشن، ممکنه با + race condition رو‌به‌رو بشیم (چون بین `get_by_odoo_id` و `insert` قفلی + نیست). برای این پروژه (اجرای دستی/زمان‌بندی‌شده‌ی تکی) مشکلی ایجاد نمی‌کنه. +- **تست‌ها فقط Unit Test هستن**: به دلیل محدودیت زمانی، Integration Test + رسمی (که مستقیم به یک Odoo/Postgres واقعی وصل بشه و end-to-end تست کنه) + نوشته نشده؛ اسکریپت‌های `scripts/test_*.py` این نقش رو به‌صورت دستی ایفا + می‌کنن. +- **Incremental Sync بر پایه‌ی `write_date`**: اگه یک رکورد بین دو اجرای + Sync هم ساخته و هم حذف بشه (خیلی به‌ندرت پیش میاد)، ممکنه در حالت + Incremental دیده نشه. برای Full Sync این مشکل وجود نداره. diff --git a/docs/USER_DOCUMENTATION.md b/docs/USER_DOCUMENTATION.md new file mode 100644 index 0000000..0c57798 --- /dev/null +++ b/docs/USER_DOCUMENTATION.md @@ -0,0 +1,202 @@ +# User Documentation — راهنمای نصب و اجرا + +این راهنما فرض می‌کنه پروژه رو تازه Clone/Fork کردی و می‌خوای از صفر اجراش کنی. + +## پیش‌نیازها + +- Docker و Docker Compose +- Python 3.11 یا بالاتر (فقط لازم داری اگه بخوای روش ۲ رو انجام بدی) + +این پروژه رو میشه به ۲ روش اجرا کرد: +- **روش ۱ (پیشنهادی، طبق مستندات آزمون): کامل داخل Docker** +- **روش ۲: دستی روی سیستم خودت** (برای توسعه/دیباگ راحت‌تر) + +--- + +## روش ۱: اجرای کامل با Docker + +### مرحله ۱: بالا آوردن همه‌چیز + +```bash +docker compose up -d +``` + +این دستور Odoo، دیتابیس اختصاصی Odoo، دیتابیس اپلیکیشن، و Backend پایتون +(سرویس `app`، که به‌محض بالا اومدن `alembic upgrade head` رو خودکار اجرا +می‌کنه) رو بالا میاره. با این دستور مطمئن شو `healthy` شدن: + +```bash +docker compose ps +``` + +### مرحله ۲: ساخت دیتابیس در Odoo (یک‌بار، دستی) + +این تنها قدمیه که خودکار نیست (چون Odoo تازه‌نصب‌شده هیچ دیتابیسی نداره و +ساختنش نیاز به وارد کردن دستی اطلاعات داره): + +1. برو به `http://localhost:8069` +2. تو فرم "Create Database": + - Database Name: `sync_test` + - Email: هرچی دلت خواست + - Password: یه پسورد بذار و یادت بمونه + - تیک Demo Data رو بردار +3. بعد از ساخت، از منوی **Apps** اپ **Sales** رو نصب کن. + +### مرحله ۳: فایل `.env` رو با پسورد واقعی پر کن + +```bash +cp .env.example .env +``` + +`ODOO_PASSWORD` رو با پسوردی که تو مرحله‌ی ۲ ساختی جایگزین کن. +`docker-compose.yml` این مقدار رو از همین فایل می‌خونه و به کانتینر `app` +تزریق می‌کنه (بدون اینکه خود `.env` وارد Docker Image بشه). + +اگه `.env` رو بعد از `docker compose up` عوض کردی: +```bash +docker compose up -d app +``` + +### مرحله ۴: ساخت داده‌ی تستی در Odoo + +```bash +docker compose run --rm app python odoo/init_data.py +``` + +### مرحله ۵: اجرای Sync + +```bash +docker compose run --rm app python -m src.main +``` + +هر چندبار بخوای می‌تونی دوباره اجراش کنی — به‌خاطر Idempotent Design، داده‌ی +تکراری ساخته نمیشه. + +### مرحله ۶: اجرای تست‌ها + +```bash +docker compose run --rm app pytest -v +``` + +--- + +## روش ۲: اجرای دستی روی سیستم خودت (برای توسعه) + +### مرحله ۱: بالا آوردن Odoo و PostgreSQL + +```bash +docker compose up -d odoo odoo-db app-db +``` + +(سرویس `app` رو صدا نمی‌زنیم، چون خودمون دستی کد رو اجرا می‌کنیم) + +### مرحله ۲: ساخت دیتابیس در Odoo + +مثل روش ۱، از طریق `http://localhost:8069`. + +### مرحله ۳: تنظیم فایل `.env` + +```bash +cp .env.example .env +``` + +مقادیر زیر رو با مقادیر واقعی خودت جایگزین کن: + +``` +ODOO_URL=http://localhost:8069 +ODOO_DB=sync_test +ODOO_USERNAME=admin +ODOO_PASSWORD=<پسوردی که تو مرحله ۲ ساختی> + +APP_DB_HOST=localhost +APP_DB_PORT=5433 +``` + +> **نکته:** چون این‌بار کد رو مستقیم روی ویندوز/مک/لینوکس خودت اجرا +> می‌کنی (نه داخل کانتینر)، همیشه از `localhost` استفاده کن، نه اسم +> سرویس‌های داکر مثل `odoo` یا `app-db` (آن‌ها فقط از *داخل* شبکه‌ی +> داکر قابل‌شناساییند). + +### مرحله ۴: نصب پکیج‌های پایتون + +```bash +python -m venv .venv +source .venv/bin/activate # ویندوز: .venv\Scripts\Activate.ps1 +pip install -r requirements.txt +pip install -r requirements-dev.txt +``` + +### مرحله ۵: ساخت جدول‌های PostgreSQL + +```bash +alembic upgrade head +``` + +### مرحله ۶: ساخت داده‌ی تستی در Odoo + +```bash +python odoo/init_data.py +``` + +این اسکریپت idempotent هست — اگه دوباره اجراش کنی، رکورد تکراری نمی‌سازه. + +### مرحله ۷: اجرای فرآیند Sync اصلی + +```bash +python -m src.main +``` + +خروجی موفق: +``` +========== نتیجه‌ی Sync ========== +contacts_sync | fetched= 3 created= 3 updated= 0 failed= 0 status=success +products_sync | fetched= 3 created= 3 updated= 0 failed= 0 status=success +sale_orders_sync | fetched= 3 created= 3 updated= 0 failed= 0 status=success +sale_order_lines_sync | fetched= 5 created= 5 updated= 0 failed= 0 status=success +=================================== +``` + +می‌تونی این دستور رو چندبار اجرا کنی؛ بار دوم به بعد باید `created=0` و +`updated>0` ببینی (یعنی رکورد تکراری ساخته نشده). از دومین اجرا به بعد، +Sync به‌شکل Incremental انجام میشه — یعنی فقط رکوردهایی که از آخرین Sync +موفق به بعد تو Odoo تغییر کردن خونده میشن، نه کل جدول. + +لاگ‌ها هم‌زمان تو Terminal، فایل `sync.log`، و جدول‌های `sync_runs` / +`sync_logs` داخل PostgreSQL ثبت میشن. + +### مرحله ۸: اجرای تست‌ها + +```bash +pytest -v +``` + +برای دیدن Test Coverage: +```bash +pytest --cov=src --cov-report=term-missing +``` + +--- + +## بررسی مستقیم داده‌ها (اختیاری) + +```bash +python -c "from src.db.session import get_session; from src.db.models import Contact; s = get_session(); print([c.name for c in s.query(Contact).all()])" +``` + +## تست Graceful Shutdown (اختیاری) + +وسط اجرای `python -m src.main` یه Ctrl+C بزن (یا اگه داخل Docker اجرا شده، +`docker compose stop app` رو بزن). باید تو لاگ ببینی که پیام "سیگنال توقف +دریافت شد" چاپ میشه و برنامه تمیز بسته میشه، بدون خرابی داده. اگه دوباره +`sync_runs` رو چک کنی، یه ردیف با `status='cancelled'` می‌بینی. + +## اگه چیزی کار نکرد + +- **`ConnectionRefusedError`**: مطمئن شو `docker compose ps` نشون میده + `odoo` و `app-db` روشن و healthy هستن. +- **خطای احراز هویت Odoo**: مقادیر `ODOO_USERNAME`/`ODOO_PASSWORD`/`ODOO_DB` + رو تو `.env` با چیزی که موقع ساخت دیتابیس (مرحله ۲) وارد کردی مقایسه کن. +- **`could not translate host name`**: تو روش ۲ (اجرای دستی)، جایی تو + `.env` از اسم سرویس داکر (`odoo`, `app-db`) به‌جای `localhost` استفاده + شده. تو روش ۱ (Docker)، این پیام عادی نیست و یعنی سرویس `app` هنوز به + شبکه‌ی داکر وصل نشده — `docker compose ps` رو چک کن. diff --git a/migrations/__pycache__/env.cpython-313.pyc b/migrations/__pycache__/env.cpython-313.pyc new file mode 100644 index 0000000..a569008 Binary files /dev/null and b/migrations/__pycache__/env.cpython-313.pyc differ diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..1162833 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,84 @@ +import sys +from logging.config import fileConfig +from pathlib import Path +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +# اضافه کردن ریشه‌ی پروژه به sys.path که بتونیم src رو import کنیم +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.config.settings import settings # noqa: E402 +from src.db.models import Base # noqa: E402 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# آدرس دیتابیس رو از settings.py می‌گیریم، نه از alembic.ini +config.set_main_option("sqlalchemy.url", settings.database_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# این خط باعث میشه Alembic بتونه خودش تشخیص بده جدول‌ها چطور باید بسازه (autogenerate) +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/8f898b04173e_create_initial_tables.py b/migrations/versions/8f898b04173e_create_initial_tables.py new file mode 100644 index 0000000..945f960 --- /dev/null +++ b/migrations/versions/8f898b04173e_create_initial_tables.py @@ -0,0 +1,117 @@ +"""create initial tables + +Revision ID: 8f898b04173e +Revises: +Create Date: 2026-07-26 19:11:05.149073 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8f898b04173e' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('contacts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('odoo_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('mobile', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_contacts_odoo_id'), 'contacts', ['odoo_id'], unique=True) + op.create_table('products', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('odoo_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('internal_reference', sa.String(length=100), nullable=True), + sa.Column('sale_price', sa.Float(), nullable=False), + sa.Column('product_type', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_products_internal_reference'), 'products', ['internal_reference'], unique=False) + op.create_index(op.f('ix_products_odoo_id'), 'products', ['odoo_id'], unique=True) + op.create_table('sync_runs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('operation_type', sa.String(length=100), nullable=False), + sa.Column('started_at', sa.DateTime(), nullable=False), + sa.Column('finished_at', sa.DateTime(), nullable=True), + sa.Column('status', sa.String(length=50), nullable=True), + sa.Column('records_fetched', sa.Integer(), nullable=True), + sa.Column('records_created', sa.Integer(), nullable=True), + sa.Column('records_updated', sa.Integer(), nullable=True), + sa.Column('records_failed', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('sale_orders', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('odoo_id', sa.Integer(), nullable=False), + sa.Column('order_number', sa.String(length=100), nullable=False), + sa.Column('customer_id', sa.Integer(), nullable=False), + sa.Column('order_date', sa.DateTime(), nullable=True), + sa.Column('state', sa.String(length=50), nullable=True), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['customer_id'], ['contacts.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sale_orders_odoo_id'), 'sale_orders', ['odoo_id'], unique=True) + op.create_table('sync_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sync_run_id', sa.Integer(), nullable=False), + sa.Column('level', sa.String(length=20), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('record_reference', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['sync_run_id'], ['sync_runs.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('sale_order_lines', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('odoo_id', sa.Integer(), nullable=False), + sa.Column('sale_order_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Float(), nullable=False), + sa.Column('unit_price', sa.Float(), nullable=False), + sa.Column('subtotal', sa.Float(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.ForeignKeyConstraint(['sale_order_id'], ['sale_orders.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sale_order_lines_odoo_id'), 'sale_order_lines', ['odoo_id'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_sale_order_lines_odoo_id'), table_name='sale_order_lines') + op.drop_table('sale_order_lines') + op.drop_table('sync_logs') + op.drop_index(op.f('ix_sale_orders_odoo_id'), table_name='sale_orders') + op.drop_table('sale_orders') + op.drop_table('sync_runs') + op.drop_index(op.f('ix_products_odoo_id'), table_name='products') + op.drop_index(op.f('ix_products_internal_reference'), table_name='products') + op.drop_table('products') + op.drop_index(op.f('ix_contacts_odoo_id'), table_name='contacts') + op.drop_table('contacts') + # ### end Alembic commands ### diff --git a/migrations/versions/__pycache__/8f898b04173e_create_initial_tables.cpython-313.pyc b/migrations/versions/__pycache__/8f898b04173e_create_initial_tables.cpython-313.pyc new file mode 100644 index 0000000..f14b078 Binary files /dev/null and b/migrations/versions/__pycache__/8f898b04173e_create_initial_tables.cpython-313.pyc differ diff --git a/odoo/init_data.py b/odoo/init_data.py new file mode 100644 index 0000000..ee104b4 --- /dev/null +++ b/odoo/init_data.py @@ -0,0 +1,196 @@ +""" +اسکریپت ساخت داده‌های تستی در Odoo (Contacts, Products, Sale Orders). + +این اسکریپت idempotent هست: اگه دوباره اجراش کنی، رکوردهای تکراری نمی‌سازه +(چون قبل از ساخت هر رکورد، چک می‌کنه که با همون شناسه (مثلاً همون ایمیل یا +همون Internal Reference) قبلاً وجود داره یا نه). + +اجرا: + python odoo/init_data.py +""" + +import os +import ssl +import xmlrpc.client +from pathlib import Path +from dotenv import load_dotenv + +# ابتدا تلاش می‌کند .env.local را بخواند، اگر نبود به سراغ .env می‌رود +load_dotenv(".env.local") or load_dotenv(".env") + +# # ---------- خواندن ساده‌ی فایل .env.local یا .env بدون نیاز به کتابخانه‌ی خارجی ---------- +# def load_env(base_dir: str | Path = ".") -> None: +# base_path = Path(base_dir) +# # اولویت اول با .env.local، در غیر این صورت .env +# env_file = base_path / ".env.local" +# if not env_file.exists(): +# env_file = base_path / ".env" + +# if not env_file.exists(): +# return + +# for line in env_file.read_text(encoding="utf-8").splitlines(): +# line = line.strip() +# if not line or line.startswith("#") or "=" not in line: +# continue +# key, value = line.split("=", 1) +# # مقداردهی مستقیم تا مقادیر فایل حتماً روی os.environ اعمال شوند +# os.environ[key.strip()] = value.strip() + + +# load_env() + +ODOO_URL = os.getenv("ODOO_URL", "http://localhost:8069") +ODOO_DB = os.getenv("ODOO_DB", "my_odoo_project") +ODOO_USERNAME = os.getenv("ODOO_USERNAME", "admin@gmail.com") +ODOO_PASSWORD = os.getenv("ODOO_PASSWORD", "Mohamad@77") + + +class OdooConnection: + """یک wrapper ساده روی XML-RPC برای اتصال و صدا زدن متدهای Odoo.""" + + def __init__(self, url: str, db: str, username: str, password: str): + self.url = url + self.db = db + self.username = username + self.password = password + self.uid = None + self.models = None + + def connect(self): + common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common") + self.uid = common.authenticate(self.db, self.username, self.password, {}) + if not self.uid: + raise RuntimeError( + "اتصال به Odoo ناموفق بود. یوزرنیم/پسورد یا اسم دیتابیس رو چک کن." + ) + self.models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object") + print(f"[OK] به Odoo وصل شدیم. uid={self.uid}") + return self + + def execute(self, model: str, method: str, *args, **kwargs): + return self.models.execute_kw( + self.db, self.uid, self.password, model, method, list(args), kwargs + ) + + def search(self, model: str, domain: list) -> list: + return self.execute(model, "search", domain) + + def search_read(self, model: str, domain: list, fields: list) -> list: + return self.execute(model, "search_read", domain, {"fields": fields}) + + def create(self, model: str, values: dict) -> int: + return self.execute(model, "create", values) + + def write(self, model: str, record_id: int, values: dict) -> bool: + return self.execute(model, "write", [record_id], values) + + +def get_or_create_contact(conn: OdooConnection, data: dict) -> int: + """اگه Contact با همین ایمیل وجود داشت همونو برمی‌گردونه، وگرنه می‌سازه.""" + existing = conn.search("res.partner", [["email", "=", data["email"]]]) + if existing: + print(f" - Contact '{data['name']}' از قبل هست (id={existing[0]}), رد شدیم.") + return existing[0] + contact_id = conn.create("res.partner", data) + print(f" + Contact '{data['name']}' ساخته شد (id={contact_id})") + return contact_id + + +def get_or_create_product(conn: OdooConnection, data: dict) -> int: + """اگه Product با همین Internal Reference (default_code) وجود داشت، همونو برمی‌گردونه.""" + existing = conn.search("product.product", [["default_code", "=", data["default_code"]]]) + if existing: + print(f" - Product '{data['name']}' از قبل هست (id={existing[0]}), رد شدیم.") + return existing[0] + product_id = conn.create("product.product", data) + print(f" + Product '{data['name']}' ساخته شد (id={product_id})") + return product_id + + +def get_or_create_sale_order(conn: OdooConnection, partner_id: int, order_date: str, + lines: list, client_order_ref: str) -> int: + """ + برای جلوگیری از تکرار، از client_order_ref (فیلد "Customer Reference") + به عنوان شناسه‌ی یکتا استفاده می‌کنیم چون در این سناریو مصنوعیه و + راحت می‌تونیم تشخیص بدیم قبلاً ساخته شده یا نه. + """ + existing = conn.search("sale.order", [["client_order_ref", "=", client_order_ref]]) + if existing: + print(f" - Sale Order '{client_order_ref}' از قبل هست (id={existing[0]}), رد شدیم.") + return existing[0] + + order_lines = [ + (0, 0, { + "product_id": line["product_id"], + "product_uom_qty": line["qty"], + "price_unit": line["price_unit"], + }) + for line in lines + ] + + order_id = conn.create("sale.order", { + "partner_id": partner_id, + "date_order": order_date, + "client_order_ref": client_order_ref, + "order_line": order_lines, + }) + print(f" + Sale Order '{client_order_ref}' ساخته شد (id={order_id})") + return order_id + + +def main(): + conn = OdooConnection(ODOO_URL, ODOO_DB, ODOO_USERNAME, ODOO_PASSWORD).connect() + + print("\n--- ساخت Contacts ---") + contacts_data = [ + {"name": "Ali Rezaei", "email": "ali.rezaei@example.com", "phone": "+98 21 1111 1111", "mobile": "+98 912 111 1111"}, + {"name": "Sara Ahmadi", "email": "sara.ahmadi@example.com", "phone": "+98 21 2222 2222", "mobile": "+98 912 222 2222"}, + {"name": "Reza Karimi", "email": "reza.karimi@example.com", "phone": "+98 21 3333 3333", "mobile": "+98 912 333 3333"}, + ] + contact_ids = [get_or_create_contact(conn, c) for c in contacts_data] + + print("\n--- ساخت Products ---") + products_data = [ + {"name": "Wireless Mouse", "default_code": "PRD-001", "list_price": 25.0, "type": "consu"}, + {"name": "Mechanical Keyboard", "default_code": "PRD-002", "list_price": 75.0, "type": "consu"}, + {"name": "Consulting Service", "default_code": "PRD-003", "list_price": 100.0, "type": "service"}, + ] + product_ids = [get_or_create_product(conn, p) for p in products_data] + + print("\n--- ساخت Sale Orders ---") + get_or_create_sale_order( + conn, + partner_id=contact_ids[0], + order_date="2026-06-01 10:00:00", + client_order_ref="TEST-SO-001", + lines=[ + {"product_id": product_ids[0], "qty": 2, "price_unit": 25.0}, + {"product_id": product_ids[1], "qty": 1, "price_unit": 75.0}, + ], + ) + get_or_create_sale_order( + conn, + partner_id=contact_ids[1], + order_date="2026-06-05 14:30:00", + client_order_ref="TEST-SO-002", + lines=[ + {"product_id": product_ids[2], "qty": 3, "price_unit": 100.0}, + ], + ) + get_or_create_sale_order( + conn, + partner_id=contact_ids[2], + order_date="2026-06-10 09:15:00", + client_order_ref="TEST-SO-003", + lines=[ + {"product_id": product_ids[0], "qty": 5, "price_unit": 25.0}, + {"product_id": product_ids[2], "qty": 1, "price_unit": 100.0}, + ], + ) + + print("\n[DONE] داده‌های تستی آماده‌ست.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..73e0393 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ + -r requirements.txt + pytest==8.3.4 + pytest-cov==6.0.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a9a0b35 Binary files /dev/null and b/requirements.txt differ diff --git a/scripts/__pycache__/test_mapping.cpython-313-pytest-8.3.4.pyc b/scripts/__pycache__/test_mapping.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..2261f4a Binary files /dev/null and b/scripts/__pycache__/test_mapping.cpython-313-pytest-8.3.4.pyc differ diff --git a/scripts/__pycache__/test_odoo_connection.cpython-313-pytest-8.3.4.pyc b/scripts/__pycache__/test_odoo_connection.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..adb945b Binary files /dev/null and b/scripts/__pycache__/test_odoo_connection.cpython-313-pytest-8.3.4.pyc differ diff --git a/scripts/__pycache__/test_repository_sync.cpython-313-pytest-8.3.4.pyc b/scripts/__pycache__/test_repository_sync.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..e21e3f9 Binary files /dev/null and b/scripts/__pycache__/test_repository_sync.cpython-313-pytest-8.3.4.pyc differ diff --git a/scripts/test_mapping.py b/scripts/test_mapping.py new file mode 100644 index 0000000..016beee --- /dev/null +++ b/scripts/test_mapping.py @@ -0,0 +1,74 @@ +""" +اسکریپت تست مرحله ۵: چک می‌کنه Mapperها درست کار می‌کنن. + +اجرا: + python scripts/test_mapping.py +""" + +import os +import sys +from pathlib import Path + +# ۱. افزودن مسیر اصلی پروژه به sys.path +BASE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BASE_DIR)) + + +# ۲. بارگذاری فایل .env.local یا .env +def load_env(base_dir: Path) -> None: + # اولویت اول با .env.local، در غیر این صورت .env + env_file = base_dir / ".env.local" + if not env_file.exists(): + env_file = base_dir / ".env" + + if not env_file.exists(): + return + + print(f"[INFO] Loading environment from: {env_file.name}") + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ[key.strip()] = value.strip() + + +load_env(BASE_DIR) + +from src.odoo_client.client import build_odoo_client +from src.mappers.contact_mapper import ContactMapper +from src.mappers.product_mapper import ProductMapper +from src.mappers.sale_order_mapper import SaleOrderMapper +from src.mappers.sale_order_line_mapper import SaleOrderLineMapper + + +def main(): + client = build_odoo_client() + + print("--- Contacts ---") + raw_contacts = client.fetch_contacts() + contacts = ContactMapper().to_domain_list(raw_contacts) + for c in contacts: + print(" ", c) + + print("\n--- Products ---") + raw_products = client.fetch_products() + products = ProductMapper().to_domain_list(raw_products) + for p in products: + print(" ", p) + + print("\n--- Sale Orders ---") + raw_orders = client.fetch_sale_orders() + orders = SaleOrderMapper().to_domain_list(raw_orders) + for o in orders: + print(" ", o) + + print("\n--- Sale Order Lines ---") + raw_lines = client.fetch_sale_order_lines() + lines = SaleOrderLineMapper().to_domain_list(raw_lines) + for l in lines: + print(" ", l) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_odoo_connection.py b/scripts/test_odoo_connection.py new file mode 100644 index 0000000..61e3db2 --- /dev/null +++ b/scripts/test_odoo_connection.py @@ -0,0 +1,73 @@ +""" +اسکریپت تست مرحله ۴: فقط چک می‌کنه OdooClient درست وصل میشه و می‌تونه +داده بخونه. هیچی نمی‌نویسه، فقط می‌خونه و چاپ می‌کنه. + +اجرا: + python scripts/test_odoo_connection.py +""" +import os +import sys +from pathlib import Path + +# ۱. اضافه کردن مسیر پروژه به sys.path +BASE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BASE_DIR)) + +# ۲. تابع بارگذاری .env.local یا .env (بدون setdefault تا حتماً مقادیر اعمال شوند) +def load_env(base_dir: Path) -> None: + # اولویت اول با .env.local است؛ اگر نبود سراغ .env می‌رود + env_file = base_dir / ".env.local" + if not env_file.exists(): + env_file = base_dir / ".env" + + if not env_file.exists(): + return + + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ[key.strip()] = value.strip() + + +# بارگذاری فایل محیطی قبل از فراخوانی odoo_client +load_env(BASE_DIR) + +# sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.odoo_client.client import build_odoo_client + + +def main(): + print("در حال اتصال به Odoo...") + client = build_odoo_client() + print("[OK] وصل شدیم.\n") + + contacts = client.fetch_contacts() + print(f"تعداد Contacts خونده‌شده: {len(contacts)}") + for c in contacts[:3]: + print(f" - {c['name']} | {c.get('email')}") + + products = client.fetch_products() + print(f"\nتعداد Products خونده‌شده: {len(products)}") + for p in products[:3]: + print( + f" - {p['name']} | ref={p.get('default_code')} |" + f" price={p.get('list_price')}" + ) + + orders = client.fetch_sale_orders() + print(f"\nتعداد Sale Orders خونده‌شده: {len(orders)}") + for o in orders[:3]: + print( + f" - {o['name']} | state={o.get('state')} |" + f" total={o.get('amount_total')}" + ) + + lines = client.fetch_sale_order_lines() + print(f"\nتعداد Sale Order Lines خونده‌شده: {len(lines)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_repository_sync.py b/scripts/test_repository_sync.py new file mode 100644 index 0000000..28c15d4 --- /dev/null +++ b/scripts/test_repository_sync.py @@ -0,0 +1,121 @@ +""" +اسکریپت تست مرحله ۶: کل مسیر «Odoo -> Mapper -> Repository -> PostgreSQL» رو +یک‌بار کامل اجرا می‌کنه. این هنوز Service Layer رسمی نیست (اون مرحله‌ی بعده)، +فقط برای اینه که ببینیم Repositoryها درست کار می‌کنن. + +نکته‌ی مهم برای تست Idempotency: +این اسکریپت رو ۲ بار پشت‌سرهم اجرا کن. بار اول باید همه‌چیز "created" بشه، +بار دوم باید همه‌چیز "updated" بشه (نه created) — یعنی رکورد تکراری ساخته نشد. + +اجرا: + python scripts/test_repository_sync.py +""" + +import os +import sys +from pathlib import Path + +# ۱. یافتن مسیر ریشه پروژه و افزودن به sys.path +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE_DIR)) + + +# ۲. بارگذاری فایل .env.local یا .env +def load_env(base_dir: Path) -> None: + # اولویت اول با .env.local هست، اگه نبود .env رو میخونه + env_file = base_dir / ".env.local" + if not env_file.exists(): + env_file = base_dir / ".env" + + if not env_file.exists(): + print("[WARN] هیچ فایل .env یا .env.local پیدا نشد!") + return + + print(f"[INFO] Loading environment from: {env_file.name}") + for line in env_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + # استفاده از مقداردهی مستقیم برای بازنویسی متغیرهای قبلی + os.environ[key.strip()] = value.strip() + + +load_env(BASE_DIR) + +from src.odoo_client.client import build_odoo_client +from src.mappers.contact_mapper import ContactMapper +from src.mappers.product_mapper import ProductMapper +from src.mappers.sale_order_mapper import SaleOrderMapper +from src.mappers.sale_order_line_mapper import SaleOrderLineMapper +from src.db.session import get_session +from src.repositories.contact_repository import ContactRepository +from src.repositories.product_repository import ProductRepository +from src.repositories.sale_order_repository import SaleOrderRepository +from src.repositories.sale_order_line_repository import SaleOrderLineRepository + + +def main(): + client = build_odoo_client() + session = get_session() + + contact_repo = ContactRepository(session) + product_repo = ProductRepository(session) + order_repo = SaleOrderRepository(session) + line_repo = SaleOrderLineRepository(session) + + stats = {"created": 0, "updated": 0} + + try: + # ---------- Contacts ---------- + print("--- Sync Contacts ---") + contacts = ContactMapper().to_domain_list(client.fetch_contacts()) + for c in contacts: + _, created = contact_repo.upsert(c) + stats["created" if created else "updated"] += 1 + print(f" {'created' if created else 'updated'}: {c.name}") + + # ---------- Products ---------- + print("\n--- Sync Products ---") + products = ProductMapper().to_domain_list(client.fetch_products()) + for p in products: + _, created = product_repo.upsert(p) + stats["created" if created else "updated"] += 1 + print(f" {'created' if created else 'updated'}: {p.name}") + + # ---------- Sale Orders ---------- + print("\n--- Sync Sale Orders ---") + orders = SaleOrderMapper().to_domain_list(client.fetch_sale_orders()) + for o in orders: + customer = contact_repo.get_by_odoo_id(o.customer_odoo_id) + if customer is None: + print(f" [SKIP] مشتری با odoo_id={o.customer_odoo_id} پیدا نشد.") + continue + _, created = order_repo.upsert(o, customer_id=customer.id) + stats["created" if created else "updated"] += 1 + print(f" {'created' if created else 'updated'}: {o.order_number}") + + # ---------- Sale Order Lines ---------- + print("\n--- Sync Sale Order Lines ---") + lines = SaleOrderLineMapper().to_domain_list(client.fetch_sale_order_lines()) + for line in lines: + order = order_repo.get_by_odoo_id(line.order_odoo_id) + product = product_repo.get_by_odoo_id(line.product_odoo_id) + if order is None or product is None: + print(f" [SKIP] order یا product برای line odoo_id={line.odoo_id} پیدا نشد.") + continue + _, created = line_repo.upsert(line, sale_order_id=order.id, product_id=product.id) + stats["created" if created else "updated"] += 1 + + session.commit() + print(f"\n[DONE] created={stats['created']} updated={stats['updated']}") + + except Exception: + session.rollback() + raise + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/bookland_account/__init__.py b/src/__init__.py similarity index 100% rename from bookland_account/__init__.py rename to src/__init__.py diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d30f730 Binary files /dev/null and b/src/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/__pycache__/main.cpython-313.pyc b/src/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..7a1f793 Binary files /dev/null and b/src/__pycache__/main.cpython-313.pyc differ diff --git a/bookland/README.md b/src/config/__init__.py similarity index 100% rename from bookland/README.md rename to src/config/__init__.py diff --git a/src/config/__pycache__/__init__.cpython-313.pyc b/src/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..4364cf2 Binary files /dev/null and b/src/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/config/__pycache__/settings.cpython-313.pyc b/src/config/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000..f2e2499 Binary files /dev/null and b/src/config/__pycache__/settings.cpython-313.pyc differ diff --git a/src/config/settings.py b/src/config/settings.py new file mode 100644 index 0000000..b920bdc --- /dev/null +++ b/src/config/settings.py @@ -0,0 +1,52 @@ +""" +تمام تنظیمات پروژه از اینجا خونده میشه. اگه فردا خواستی یه env variable +جدید اضافه کنی، فقط همینجا اضافه‌اش می‌کنی و بقیه‌ی کد بدون تغییر می‌مونه. +""" + +import os +from dotenv import load_dotenv +from pathlib import Path + + +#پیدا کردن مسیر ریشه از دو لایه بالاتر +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +#روی محیط local با .env.local کار میکنه و روی محیط پروداکشن با .env +env_file = BASE_DIR / ".env.local" +if not env_file.exists(): + env_file = BASE_DIR / ".env" + +if env_file.exists(): + load_dotenv(dotenv_path=env_file, override=True) + + +class Settings: + def __init__(self): + #تنظمیات odoo + self.ODOO_URL: str = os.getenv("ODOO_URL", "http://localhost:8069") + self.ODOO_DB: str = os.getenv("ODOO_DB", "my_odoo_project") + self.ODOO_USERNAME: str = os.getenv("ODOO_USERNAME", "admin@gmail.com") + self.ODOO_PASSWORD: str = os.getenv("ODOO_PASSWORD", "Mohamad@77") + + # تنظمیات postgresql + self.APP_DB_HOST: str = os.getenv("APP_DB_HOST", "localhost") + self.APP_DB_PORT: int = int(os.getenv("APP_DB_PORT", "5432")) + self.APP_DB_NAME: str = os.getenv("APP_DB_NAME", "syncdb") + self.APP_DB_USER: str = os.getenv("APP_DB_USER", "syncuser") + self.APP_DB_PASSWORD: str = os.getenv("APP_DB_PASSWORD", "syncpass") + + + # تنظیمات لاگ ها + self.LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + + #اتصال به DB + @property + def database_url(self) -> str: + """Connection string کامل برای SQLAlchemy.""" + return ( + f"postgresql+psycopg2://{self.APP_DB_USER}:{self.APP_DB_PASSWORD}" + f"@{self.APP_DB_HOST}:{self.APP_DB_PORT}/{self.APP_DB_NAME}" + ) + + +settings = Settings() diff --git a/bookland_account/README.md b/src/core/__init__.py similarity index 100% rename from bookland_account/README.md rename to src/core/__init__.py diff --git a/src/core/__pycache__/__init__.cpython-313.pyc b/src/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..346f9f9 Binary files /dev/null and b/src/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/core/__pycache__/logging_config.cpython-313.pyc b/src/core/__pycache__/logging_config.cpython-313.pyc new file mode 100644 index 0000000..9951191 Binary files /dev/null and b/src/core/__pycache__/logging_config.cpython-313.pyc differ diff --git a/src/core/__pycache__/retry.cpython-313.pyc b/src/core/__pycache__/retry.cpython-313.pyc new file mode 100644 index 0000000..b3f8cb5 Binary files /dev/null and b/src/core/__pycache__/retry.cpython-313.pyc differ diff --git a/src/core/logging_config.py b/src/core/logging_config.py new file mode 100644 index 0000000..e8acc32 --- /dev/null +++ b/src/core/logging_config.py @@ -0,0 +1,49 @@ +""" +یک‌بار تو ابتدای main.py صدا زده میشه و کل سیستم Logging پروژه رو راه +می‌اندازه. لاگ‌ها هم‌زمان به دو جا میرن: + ۱. Standard Output (خود Terminal) — برای دیدن آنی وضعیت + ۲. فایل sync.log — برای بررسی بعدی یا اگه کسی Terminal رو نبینه + +(علاوه بر این دوتا، جزئیات هر Sync Run تو جدول sync_runs/sync_logs هم +ذخیره میشه — اون بخش تو BaseSyncService انجام میشه.) +""" + +import logging +import sys +from src.config.settings import settings +from logging.handlers import RotatingFileHandler + +def setup_logging() -> None: + #لاگ های فراتر از Info رو به من نمایش میده + level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) + + # تعیین فرمت لاگ ها + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # تنظیمات لاگ ریشه + root_logger = logging.getLogger() + root_logger.setLevel(level) + root_logger.handlers.clear() + + # نمایش لاگ ها روی ترمینال + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + root_logger.addHandler(console_handler) + + # نگهداری لاگ فایل ها بامدیریت حجم + file_handler = RotatingFileHandler( + "sync.log", + maxBytes=5 * 1024 * 1024, + backupCount=3, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + root_logger.addHandler(file_handler) + + # عدم ذخیره لاگ های کتاب خانه های جانبی مثل warning , info , ... + NOISY_LOGGERS = ["urllib3", "requests", "sqlalchemy.engine", "psycopg2"] + for logger_name in NOISY_LOGGERS: + logging.getLogger(logger_name).setLevel(logging.WARNING) diff --git a/src/core/retry.py b/src/core/retry.py new file mode 100644 index 0000000..ad82534 --- /dev/null +++ b/src/core/retry.py @@ -0,0 +1,64 @@ +""" +Retry Decorator: وقتی یک تابع به یه دلیل موقتی (مثلاً قطعی لحظه‌ای شبکه) خطا +میده، به‌جای اینکه فوری شکست بخوریم، چندبار با یه مکث بین هر تلاش دوباره +امتحان می‌کنیم. مکث هر بار بیشتر میشه (Exponential Backoff) که فشار زیادی +هم به سرور مقصد وارد نکنیم. + +مثال: delay=1, backoff=2, times=3 + تلاش ۱ شکست -> ۱ ثانیه صبر + تلاش ۲ شکست -> ۲ ثانیه صبر + تلاش ۳ شکست -> دیگه تلاش نمی‌کنیم، خطا رو بالا می‌فرستیم +""" + +import functools +import logging +import time +from typing import Tuple, Type + +logger = logging.getLogger(__name__) + +# خطاهایی که تحت هیچ شرایطی نباید retry بشن -- این‌ها خطاهای منطقی/برنامه‌نویسی +# هستن (نه مشکل موقتی شبکه)، پس retry کردنشون فقط وقت تلف می‌کنه و نتیجه +# همیشه یکی می‌مونه. +NON_RETRYABLE_EXCEPTIONS = ( + KeyboardInterrupt, + SystemExit, + TypeError, + ValueError, + KeyError, + AttributeError, + IndexError, +) + + +def retry( + times: int = 3, + delay: float = 1.0, + backoff: float = 2.0, + max_delay: float = 30.0, # سقف زمان انتظار بین تلاش‌ها (به ثانیه) + exceptions: Tuple[Type[BaseException], ...] = (Exception,), +): + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + current_delay = delay + for attempt in range(1, times + 1): + try: + return func(*args, **kwargs) + except NON_RETRYABLE_EXCEPTIONS as exc: + logger.error(f"خطای غیرقابل بازتلاش در {func.__name__}: {exc}") + raise + except exceptions as exc: + if attempt == times: + logger.error( + f"{func.__name__} بعد از {times} تلاش شکست خورد: {exc}" + ) + raise + logger.warning( + f"تلاش {attempt}/{times} برای {func.__name__} شکست خورد: " + f"{exc} — {current_delay:.1f}s صبر می‌کنیم و دوباره امتحان می‌کنیم..." + ) + time.sleep(current_delay) + current_delay = min(current_delay * backoff, max_delay) + return wrapper + return decorator diff --git a/bookland_account/readme/HISTORY.md b/src/db/__init__.py similarity index 100% rename from bookland_account/readme/HISTORY.md rename to src/db/__init__.py diff --git a/src/db/__pycache__/__init__.cpython-313.pyc b/src/db/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..2f2155f Binary files /dev/null and b/src/db/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/db/__pycache__/models.cpython-313.pyc b/src/db/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..9926146 Binary files /dev/null and b/src/db/__pycache__/models.cpython-313.pyc differ diff --git a/src/db/__pycache__/session.cpython-313.pyc b/src/db/__pycache__/session.cpython-313.pyc new file mode 100644 index 0000000..4c1f952 Binary files /dev/null and b/src/db/__pycache__/session.cpython-313.pyc differ diff --git a/src/db/models.py b/src/db/models.py new file mode 100644 index 0000000..ba5bc78 --- /dev/null +++ b/src/db/models.py @@ -0,0 +1,143 @@ +""" +مدل‌های SQLAlchemy — هر کلاس اینجا معادل یک جدول تو PostgreSQL هست. + +نکته‌ی مهم درباره‌ی Idempotency: +هر جدولی که از Odoo میاد (Contact, Product, SaleOrder, SaleOrderLine) یک ستون +`odoo_id` داره که UNIQUE هست. یعنی دیتابیس خودش جلوی ساخت رکورد تکراری با +همون odoo_id رو می‌گیره، و ما تو کد هم قبلش چک می‌کنیم (get_or_create). +""" + +from datetime import datetime + +from sqlalchemy import ( + Column, Integer, String, Float, DateTime, ForeignKey, Text, UniqueConstraint +) +from sqlalchemy.orm import declarative_base, relationship + +Base = declarative_base() + + +class Contact(Base): + __tablename__ = "contacts" + + id = Column(Integer, primary_key=True) + odoo_id = Column(Integer, unique=True, nullable=False, index=True) + name = Column(String(255), nullable=False) + email = Column(String(255), nullable=True) + phone = Column(String(50), nullable=True) + mobile = Column(String(50), nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # یک Contact می‌تونه چند تا Sale Order داشته باشه (One-to-Many) + sale_orders = relationship("SaleOrder", back_populates="customer") + + def __repr__(self): + return f"" + + +class Product(Base): + __tablename__ = "products" + + id = Column(Integer, primary_key=True) + odoo_id = Column(Integer, unique=True, nullable=False, index=True) + name = Column(String(255), nullable=False) + internal_reference = Column(String(100), nullable=True, index=True) + sale_price = Column(Float, nullable=False, default=0.0) + product_type = Column(String(50), nullable=True) # مثلاً consu / service + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + order_lines = relationship("SaleOrderLine", back_populates="product") + + def __repr__(self): + return f"" + + +class SaleOrder(Base): + __tablename__ = "sale_orders" + + id = Column(Integer, primary_key=True) + odoo_id = Column(Integer, unique=True, nullable=False, index=True) + order_number = Column(String(100), nullable=False) # مثلاً "S00001" + customer_id = Column(Integer, ForeignKey("contacts.id"), nullable=False) + order_date = Column(DateTime, nullable=True) + state = Column(String(50), nullable=True) # draft / sale / done / cancel + total_amount = Column(Float, nullable=False, default=0.0) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + customer = relationship("Contact", back_populates="sale_orders") + lines = relationship( + "SaleOrderLine", back_populates="order", cascade="all, delete-orphan" + ) + + def __repr__(self): + return f"" + + +class SaleOrderLine(Base): + __tablename__ = "sale_order_lines" + + id = Column(Integer, primary_key=True) + odoo_id = Column(Integer, unique=True, nullable=False, index=True) + sale_order_id = Column(Integer, ForeignKey("sale_orders.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Float, nullable=False, default=0.0) + unit_price = Column(Float, nullable=False, default=0.0) + subtotal = Column(Float, nullable=False, default=0.0) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + order = relationship("SaleOrder", back_populates="lines") + product = relationship("Product", back_populates="order_lines") + + def __repr__(self): + return f"" + + +class SyncRun(Base): + """ + هر بار که Sync اجرا میشه (کل فرآیند، نه هر رکورد)، یک ردیف اینجا ثبت میشه. + این جدول جواب سوال‌های "آخرین Sync کِی بود؟ چند رکورد گرفت؟ چند خطا داشت؟" رو میده. + """ + __tablename__ = "sync_runs" + + id = Column(Integer, primary_key=True) + operation_type = Column(String(100), nullable=False) # مثلاً "contacts_sync" + started_at = Column(DateTime, default=datetime.utcnow, nullable=False) + finished_at = Column(DateTime, nullable=True) + status = Column(String(50), default="running") # running / success / failed + + records_fetched = Column(Integer, default=0) + records_created = Column(Integer, default=0) + records_updated = Column(Integer, default=0) + records_failed = Column(Integer, default=0) + + logs = relationship( + "SyncLog", back_populates="sync_run", cascade="all, delete-orphan" + ) + + def __repr__(self): + return f"" + + +class SyncLog(Base): + """جزئیات هر اتفاق (مخصوصاً خطاها) در طول یک SyncRun.""" + __tablename__ = "sync_logs" + + id = Column(Integer, primary_key=True) + sync_run_id = Column(Integer, ForeignKey("sync_runs.id"), nullable=False) + level = Column(String(20), default="INFO") # INFO / WARNING / ERROR + message = Column(Text, nullable=False) + record_reference = Column(String(255), nullable=True) # مثلاً odoo_id رکورد مشکل‌دار + created_at = Column(DateTime, default=datetime.utcnow) + + sync_run = relationship("SyncRun", back_populates="logs") + + def __repr__(self): + return f"" diff --git a/src/db/session.py b/src/db/session.py new file mode 100644 index 0000000..a775532 --- /dev/null +++ b/src/db/session.py @@ -0,0 +1,46 @@ +""" +اینجا Engine (اتصال به دیتابیس) و Session (برای خواندن/نوشتن) ساخته میشه. +بقیه‌ی کد پروژه فقط از تابع get_session() استفاده می‌کنه، نباید مستقیم +اینجا رو import کنن و engine بسازن. +""" +import os +from pathlib import Path + +# ۱. تعریف بیس مسیر پروژه +BASE_DIR = Path(__file__).resolve().parent.parent.parent + + +# ۲. بارگذاری محیط قبل از هرگونه import مربوط به تنظیمات +def _auto_load_env(): + env_file = BASE_DIR / ".env.local" + if not env_file.exists(): + env_file = BASE_DIR / ".env" + + if env_file.exists(): + for line in env_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + os.environ[key.strip()] = value.strip() + + +_auto_load_env() + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from src.config.settings import settings + + +engine = create_engine(settings.database_url, echo=False, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + +def get_session(): + """ + یک Session جدید می‌سازه. استفاده‌ی پیشنهادی با context manager: + + with get_session() as session: + session.add(obj) + session.commit() + """ + return SessionLocal() diff --git a/src/domain/__init__.py b/src/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/domain/__pycache__/__init__.cpython-313.pyc b/src/domain/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..cc78c1b Binary files /dev/null and b/src/domain/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/domain/__pycache__/models.cpython-313.pyc b/src/domain/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..866aab9 Binary files /dev/null and b/src/domain/__pycache__/models.cpython-313.pyc differ diff --git a/src/domain/models.py b/src/domain/models.py new file mode 100644 index 0000000..c01e90d --- /dev/null +++ b/src/domain/models.py @@ -0,0 +1,50 @@ +""" +Domain Models: این کلاس‌ها هیچ ارتباطی با Odoo یا SQLAlchemy ندارن. فقط +داده‌ی «تمیز و معنادار» رو نگه می‌دارن. اگه فردا Odoo عوض شد به یه سیستم +دیگه، یا دیتابیس عوض شد، این کلاس‌ها دست‌نخورده می‌مونن. + +از dataclass استفاده کردیم چون برای نگه‌داشتن ساده‌ی داده، از تعریف دستی +__init__ و __repr__ راحت‌تره. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@dataclass +class Contact: + odoo_id: int + name: str + email: Optional[str] = None + phone: Optional[str] = None + mobile: Optional[str] = None + + +@dataclass +class Product: + odoo_id: int + name: str + internal_reference: Optional[str] = None + sale_price: float = 0.0 + product_type: Optional[str] = None + + +@dataclass +class SaleOrder: + odoo_id: int + order_number: str + customer_odoo_id: int # فقط شناسه‌ی Odoo مشتری؛ تبدیل به FK داخلی کار Repository هست + order_date: Optional[datetime] = None + state: Optional[str] = None + total_amount: float = 0.0 + + +@dataclass +class SaleOrderLine: + odoo_id: int + order_odoo_id: int + product_odoo_id: int + quantity: float = 0.0 + unit_price: float = 0.0 + subtotal: float = 0.0 diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..dd3ecad --- /dev/null +++ b/src/main.py @@ -0,0 +1,54 @@ +""" +نقطه‌ی ورود اصلی پروژه. کل فرآیند Sync رو یک‌جا اجرا می‌کنه. + +اجرا: + python -m src.main +""" + +import logging +import signal + +from src.core.logging_config import setup_logging +from src.db.session import get_session +from src.odoo_client.client import build_odoo_client +from src.services.sync_orchestrator import SyncOrchestrator + +logger = logging.getLogger(__name__) + + +def main(): + setup_logging() + + # Graceful Shutdown: وقتی docker stop یا Ctrl+C بزنی، سیستم عامل سیگنال + # SIGTERM/SIGINT می‌فرسته. با ثبت signal.default_int_handler، این سیگنال + # تبدیل به KeyboardInterrupt میشه -- که BaseSyncService از قبل بلده + # درست مدیریتش کنه (رکورد فعلی رو تموم می‌کنه، وضعیت رو تو sync_runs + # با status="cancelled" ذخیره می‌کنه، بعد تمیز می‌بنده). + signal.signal(signal.SIGTERM, signal.default_int_handler) + + session = get_session() + odoo_client = build_odoo_client() + + orchestrator = SyncOrchestrator(session, odoo_client) + + try: + results = orchestrator.run_all() + print("\n========== نتیجه‌ی Sync ==========") + for r in results: + print( + f"{r.operation_type:25s} | " + f"fetched={r.records_fetched:3d} " + f"created={r.records_created:3d} " + f"updated={r.records_updated:3d} " + f"failed={r.records_failed:3d} " + f"status={r.status}" + ) + print("===================================\n") + except KeyboardInterrupt: + logger.warning("Sync به‌خاطر سیگنال توقف (Ctrl+C یا docker stop) متوقف شد.") + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/src/mappers/__init__.py b/src/mappers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mappers/__pycache__/__init__.cpython-313.pyc b/src/mappers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..d977a22 Binary files /dev/null and b/src/mappers/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/mappers/__pycache__/base_mapper.cpython-313.pyc b/src/mappers/__pycache__/base_mapper.cpython-313.pyc new file mode 100644 index 0000000..ebf820e Binary files /dev/null and b/src/mappers/__pycache__/base_mapper.cpython-313.pyc differ diff --git a/src/mappers/__pycache__/contact_mapper.cpython-313.pyc b/src/mappers/__pycache__/contact_mapper.cpython-313.pyc new file mode 100644 index 0000000..3806bd3 Binary files /dev/null and b/src/mappers/__pycache__/contact_mapper.cpython-313.pyc differ diff --git a/src/mappers/__pycache__/product_mapper.cpython-313.pyc b/src/mappers/__pycache__/product_mapper.cpython-313.pyc new file mode 100644 index 0000000..019445e Binary files /dev/null and b/src/mappers/__pycache__/product_mapper.cpython-313.pyc differ diff --git a/src/mappers/__pycache__/sale_order_line_mapper.cpython-313.pyc b/src/mappers/__pycache__/sale_order_line_mapper.cpython-313.pyc new file mode 100644 index 0000000..dbd54f7 Binary files /dev/null and b/src/mappers/__pycache__/sale_order_line_mapper.cpython-313.pyc differ diff --git a/src/mappers/__pycache__/sale_order_mapper.cpython-313.pyc b/src/mappers/__pycache__/sale_order_mapper.cpython-313.pyc new file mode 100644 index 0000000..ac5c3ac Binary files /dev/null and b/src/mappers/__pycache__/sale_order_mapper.cpython-313.pyc differ diff --git a/src/mappers/base_mapper.py b/src/mappers/base_mapper.py new file mode 100644 index 0000000..f4718f5 --- /dev/null +++ b/src/mappers/base_mapper.py @@ -0,0 +1,35 @@ +""" +BaseMapper یک قرارداد (Interface) مشخص می‌کنه: هر Mapper باید یک متد +to_domain داشته باشه که dict خام Odoo رو می‌گیره و یک Domain Model برمی‌گردونه. + +این همون "Abstract Class" ایه که تو مصاحبه ازت پرسیدن. کلاس‌های فرزند +(ContactMapper, ProductMapper, ...) این متد رو پیاده‌سازی می‌کنن. +""" + +from abc import ABC, abstractmethod +from typing import Any, Generic, TypeVar + +T = TypeVar("T") + + +class BaseMapper(ABC, Generic[T]): + @abstractmethod + def to_domain(self, raw: dict[str, Any]) -> T: + """dict خام Odoo رو به یک Domain Model تبدیل می‌کنه.""" + raise NotImplementedError + + def to_domain_list(self, raw_list: list[dict[str, Any]]) -> list[T]: + """میان‌بر راحت برای تبدیل یک لیست از رکوردهای خام.""" + return [self.to_domain(raw) for raw in raw_list] + + +def extract_many2one_id(value: Any) -> int | None: + """ + فیلدهای Many2One تو Odoo به‌شکل [id, "display name"] برمی‌گردن، + یا وقتی خالی باشن False هستن. این تابع فقط id رو استخراج می‌کنه. + """ + if not value: + return None + if isinstance(value, (list, tuple)): + return value[0] + return value diff --git a/src/mappers/contact_mapper.py b/src/mappers/contact_mapper.py new file mode 100644 index 0000000..76d5ec6 --- /dev/null +++ b/src/mappers/contact_mapper.py @@ -0,0 +1,15 @@ +from typing import Any + +from src.domain.models import Contact +from src.mappers.base_mapper import BaseMapper + + +class ContactMapper(BaseMapper[Contact]): + def to_domain(self, raw: dict[str, Any]) -> Contact: + return Contact( + odoo_id=raw["id"], + name=raw["name"], + email=raw.get("email") or None, + phone=raw.get("phone") or None, + mobile=raw.get("mobile") or None, + ) diff --git a/src/mappers/product_mapper.py b/src/mappers/product_mapper.py new file mode 100644 index 0000000..b3cd625 --- /dev/null +++ b/src/mappers/product_mapper.py @@ -0,0 +1,15 @@ +from typing import Any + +from src.domain.models import Product +from src.mappers.base_mapper import BaseMapper + + +class ProductMapper(BaseMapper[Product]): + def to_domain(self, raw: dict[str, Any]) -> Product: + return Product( + odoo_id=raw["id"], + name=raw["name"], + internal_reference=raw.get("default_code") or None, + sale_price=raw.get("list_price") or 0.0, + product_type=raw.get("type") or None, + ) diff --git a/src/mappers/sale_order_line_mapper.py b/src/mappers/sale_order_line_mapper.py new file mode 100644 index 0000000..f473685 --- /dev/null +++ b/src/mappers/sale_order_line_mapper.py @@ -0,0 +1,16 @@ +from typing import Any + +from src.domain.models import SaleOrderLine +from src.mappers.base_mapper import BaseMapper, extract_many2one_id + + +class SaleOrderLineMapper(BaseMapper[SaleOrderLine]): + def to_domain(self, raw: dict[str, Any]) -> SaleOrderLine: + return SaleOrderLine( + odoo_id=raw["id"], + order_odoo_id=extract_many2one_id(raw.get("order_id")), + product_odoo_id=extract_many2one_id(raw.get("product_id")), + quantity=raw.get("product_uom_qty") or 0.0, + unit_price=raw.get("price_unit") or 0.0, + subtotal=raw.get("price_subtotal") or 0.0, + ) diff --git a/src/mappers/sale_order_mapper.py b/src/mappers/sale_order_mapper.py new file mode 100644 index 0000000..1badf82 --- /dev/null +++ b/src/mappers/sale_order_mapper.py @@ -0,0 +1,24 @@ +from datetime import datetime +from typing import Any + +from src.domain.models import SaleOrder +from src.mappers.base_mapper import BaseMapper, extract_many2one_id + + +class SaleOrderMapper(BaseMapper[SaleOrder]): + def to_domain(self, raw: dict[str, Any]) -> SaleOrder: + return SaleOrder( + odoo_id=raw["id"], + order_number=raw["name"], + customer_odoo_id=extract_many2one_id(raw.get("partner_id")), + order_date=self._parse_datetime(raw.get("date_order")), + state=raw.get("state"), + total_amount=raw.get("amount_total") or 0.0, + ) + + @staticmethod + def _parse_datetime(value: str | None) -> datetime | None: + if not value: + return None + # Odoo تاریخ رو به فرمت "YYYY-MM-DD HH:MM:SS" برمی‌گردونه + return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") diff --git a/src/odoo_client/__init__.py b/src/odoo_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/odoo_client/__pycache__/__init__.cpython-313.pyc b/src/odoo_client/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..fc74c9d Binary files /dev/null and b/src/odoo_client/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/odoo_client/__pycache__/client.cpython-313.pyc b/src/odoo_client/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000..f3503f9 Binary files /dev/null and b/src/odoo_client/__pycache__/client.cpython-313.pyc differ diff --git a/src/odoo_client/client.py b/src/odoo_client/client.py new file mode 100644 index 0000000..9448a75 --- /dev/null +++ b/src/odoo_client/client.py @@ -0,0 +1,164 @@ +""" +این ماژول تنها جاییه که مستقیم با XML-RPC Odoo کار می‌کنه. +بقیه‌ی پروژه (Service Layer) فقط از متدهای این کلاس (fetch_contacts, +fetch_products, ...) استفاده می‌کنه و نمی‌دونه پشت‌پرده XML-RPC هست یا +هر چیز دیگه — اگه فردا Odoo رو با یه پروتکل دیگه صدا زدیم، فقط همین فایل +عوض میشه. +""" + +import xmlrpc.client +from datetime import datetime +from typing import Any + +from src.config.settings import settings +from src.core.retry import retry + + +class OdooConnectionError(Exception): + """وقتی اتصال یا احراز هویت با Odoo شکست بخوره.""" + + +class OdooClient: + def __init__(self, url: str, db: str, username: str, password: str): + self.url = url + self.db = db + self.username = username + self.password = password + self._uid: int | None = None + self._models = None + + # ---------- اتصال ---------- + @retry( + times=5, + delay=2.0, + backoff=1.5, + exceptions=(ConnectionError, OSError, TimeoutError, xmlrpc.client.ProtocolError), + ) + def connect(self) -> "OdooClient": + common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common") + # نکته: authenticate رو دیگه تو try/except عمومی نمی‌گیریم که + # اگه ارور شبکه‌ای بود (مثلاً Odoo خاموشه)، بذاریم بره بالا و + # retry decorator بگیرتش. فقط خطای غیرمنتظره‌ی دیگه رو تبدیل + # به OdooConnectionError می‌کنیم. + uid = common.authenticate(self.db, self.username, self.password, {}) + + if not uid: + # پسورد/یوزرنیم/دیتابیس غلطه -- این یه خطای شبکه‌ای نیست، پس + # retry روش فایده‌ای نداره و بلافاصله باید گزارش بشه. + raise OdooConnectionError( + "احراز هویت ناموفق بود؛ یوزرنیم/پسورد یا اسم دیتابیس رو چک کن." + ) + + self._uid = uid + self._models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object") + return self + + def _ensure_connected(self): + if self._uid is None or self._models is None: + raise OdooConnectionError("قبل از استفاده باید connect() صدا زده بشه.") + + # ---------- متد عمومی برای خوندن با Pagination ---------- + @retry( + times=3, + delay=1.0, + backoff=2.0, + exceptions=(ConnectionError, OSError, TimeoutError, xmlrpc.client.ProtocolError), + ) + def search_read( + self, + model: str, + domain: list | None = None, + fields: list[str] | None = None, + offset: int = 0, + limit: int = 100, + ) -> list[dict[str, Any]]: + self._ensure_connected() + domain = domain or [] + fields = fields or [] + return self._models.execute_kw( + self.db, self._uid, self.password, + model, "search_read", + [domain], + {"fields": fields, "offset": offset, "limit": limit}, + ) + + def search_read_all( + self, + model: str, + domain: list | None = None, + fields: list[str] | None = None, + page_size: int = 100, + ) -> list[dict[str, Any]]: + """ + همه‌ی رکوردها رو صفحه‌به‌صفحه (Batch) می‌خونه، نه یک‌جا. برای حجم زیاد + داده لازمه که همه‌چیز رو با یک درخواست از Odoo نگیریم. + """ + all_records: list[dict[str, Any]] = [] + offset = 0 + while True: + page = self.search_read(model, domain, fields, offset=offset, limit=page_size) + if not page: + break + all_records.extend(page) + offset += page_size + return all_records + + # ---------- متدهای اختصاصی هر مدل ---------- + # همه‌شون پارامتر اختیاری `since` دارن: اگه پاس داده بشه، فقط رکوردهایی + # که از اون تاریخ به بعد تو Odoo تغییر کردن (write_date) برگردونده میشن + # -- این همون Incremental Sync هست، به‌جای خوندن کل جدول هر بار. + + def fetch_contacts(self, since: "datetime | None" = None) -> list[dict[str, Any]]: + domain: list = [["email", "!=", False]] + if since is not None: + domain.append(["write_date", ">=", since.strftime("%Y-%m-%d %H:%M:%S")]) + return self.search_read_all( + "res.partner", + domain=domain, + fields=["id", "name", "email", "phone", "mobile"], + ) + + def fetch_products(self, since: "datetime | None" = None) -> list[dict[str, Any]]: + domain: list = [] + if since is not None: + domain.append(["write_date", ">=", since.strftime("%Y-%m-%d %H:%M:%S")]) + return self.search_read_all( + "product.product", + domain=domain, + fields=["id", "name", "default_code", "list_price", "type"], + ) + + def fetch_sale_orders(self, since: "datetime | None" = None) -> list[dict[str, Any]]: + domain: list = [] + if since is not None: + domain.append(["write_date", ">=", since.strftime("%Y-%m-%d %H:%M:%S")]) + return self.search_read_all( + "sale.order", + domain=domain, + fields=["id", "name", "partner_id", "date_order", "state", "amount_total"], + ) + + def fetch_sale_order_lines(self, since: "datetime | None" = None) -> list[dict[str, Any]]: + domain: list = [["display_type", "=", False]] # حذف خط‌های section/note که محصول ندارن + if since is not None: + domain.append(["write_date", ">=", since.strftime("%Y-%m-%d %H:%M:%S")]) + return self.search_read_all( + "sale.order.line", + domain=domain, + fields=["id", "order_id", "product_id", "product_uom_qty", "price_unit", "price_subtotal"], + ) + + +def build_odoo_client() -> OdooClient: + """ + یک OdooClient آماده و متصل برمی‌گردونه، با استفاده از تنظیمات + داخل src/config/settings.py (که از .env می‌خونه). + بقیه‌ی کد پروژه به‌جای ساختن دستی OdooClient، همین تابع رو صدا می‌زنه. + """ + client = OdooClient( + url=settings.ODOO_URL, + db=settings.ODOO_DB, + username=settings.ODOO_USERNAME, + password=settings.ODOO_PASSWORD, + ) + return client.connect() diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/repositories/__pycache__/__init__.cpython-313.pyc b/src/repositories/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..38518b9 Binary files /dev/null and b/src/repositories/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/repositories/__pycache__/base_repository.cpython-313.pyc b/src/repositories/__pycache__/base_repository.cpython-313.pyc new file mode 100644 index 0000000..af1836e Binary files /dev/null and b/src/repositories/__pycache__/base_repository.cpython-313.pyc differ diff --git a/src/repositories/__pycache__/contact_repository.cpython-313.pyc b/src/repositories/__pycache__/contact_repository.cpython-313.pyc new file mode 100644 index 0000000..76b279f Binary files /dev/null and b/src/repositories/__pycache__/contact_repository.cpython-313.pyc differ diff --git a/src/repositories/__pycache__/product_repository.cpython-313.pyc b/src/repositories/__pycache__/product_repository.cpython-313.pyc new file mode 100644 index 0000000..63c69c9 Binary files /dev/null and b/src/repositories/__pycache__/product_repository.cpython-313.pyc differ diff --git a/src/repositories/__pycache__/sale_order_line_repository.cpython-313.pyc b/src/repositories/__pycache__/sale_order_line_repository.cpython-313.pyc new file mode 100644 index 0000000..ce60c85 Binary files /dev/null and b/src/repositories/__pycache__/sale_order_line_repository.cpython-313.pyc differ diff --git a/src/repositories/__pycache__/sale_order_repository.cpython-313.pyc b/src/repositories/__pycache__/sale_order_repository.cpython-313.pyc new file mode 100644 index 0000000..e7cd979 Binary files /dev/null and b/src/repositories/__pycache__/sale_order_repository.cpython-313.pyc differ diff --git a/src/repositories/base_repository.py b/src/repositories/base_repository.py new file mode 100644 index 0000000..9af4ddd --- /dev/null +++ b/src/repositories/base_repository.py @@ -0,0 +1,58 @@ +""" +BaseRepository منطق مشترک همه‌ی Repositoryها رو یک‌جا پیاده می‌کنه: +- get_by_odoo_id: پیدا کردن رکورد موجود +- upsert: اگه بود Update کن، نبود Insert کن (این دقیقاً همون Idempotent Design هست) + +هر Repository فرزند (ContactRepository, ProductRepository, ...) فقط باید +بگه «فیلدهای Domain Model چطور به ستون‌های ORM نگاشت میشن» — این کار تو +متد _to_orm_kwargs انجام میشه. +""" + +from abc import ABC, abstractmethod +from typing import Any, Generic, Optional, TypeVar + +from sqlalchemy.orm import Session + +ModelType = TypeVar("ModelType") +DomainType = TypeVar("DomainType") + + +class BaseRepository(ABC, Generic[ModelType, DomainType]): + model_class: type[ModelType] + + def __init__(self, session: Session): + self.session = session + + def get_by_odoo_id(self, odoo_id: int) -> Optional[ModelType]: + return ( + self.session.query(self.model_class) + .filter_by(odoo_id=odoo_id) + .first() + ) + + @abstractmethod + def _to_orm_kwargs(self, domain: DomainType, **extra: Any) -> dict[str, Any]: + """فیلدهای Domain Model رو به دیکشنری مناسب برای ساخت/آپدیت ORM تبدیل می‌کنه.""" + raise NotImplementedError + + def upsert(self, domain: DomainType, **extra: Any) -> tuple[ModelType, bool]: + """ + خروجی: (شیء ORM ذخیره‌شده، آیا تازه ساخته شد یا آپدیت شد) + + نکته: اینجا commit نمی‌کنیم! فقط flush می‌کنیم تا id بگیره ولی + Transaction هنوز باز بمونه. تصمیم commit/rollback با Service Layer هست + (چون اونجاست که "کل عملیات موفق بود یا نه" مشخص میشه). + """ + existing = self.get_by_odoo_id(domain.odoo_id) + kwargs = self._to_orm_kwargs(domain, **extra) + + if existing: + for key, value in kwargs.items(): + setattr(existing, key, value) + self.session.flush() + return existing, False + + new_obj = self.model_class(**kwargs) + self.session.add(new_obj) + self.session.flush() + return new_obj, True diff --git a/src/repositories/contact_repository.py b/src/repositories/contact_repository.py new file mode 100644 index 0000000..ee13db5 --- /dev/null +++ b/src/repositories/contact_repository.py @@ -0,0 +1,18 @@ +from typing import Any + +from src.db.models import Contact as ContactORM +from src.domain.models import Contact as ContactDomain +from src.repositories.base_repository import BaseRepository + + +class ContactRepository(BaseRepository[ContactORM, ContactDomain]): + model_class = ContactORM + + def _to_orm_kwargs(self, domain: ContactDomain, **extra: Any) -> dict[str, Any]: + return { + "odoo_id": domain.odoo_id, + "name": domain.name, + "email": domain.email, + "phone": domain.phone, + "mobile": domain.mobile, + } diff --git a/src/repositories/product_repository.py b/src/repositories/product_repository.py new file mode 100644 index 0000000..b69a4f0 --- /dev/null +++ b/src/repositories/product_repository.py @@ -0,0 +1,18 @@ +from typing import Any + +from src.db.models import Product as ProductORM +from src.domain.models import Product as ProductDomain +from src.repositories.base_repository import BaseRepository + + +class ProductRepository(BaseRepository[ProductORM, ProductDomain]): + model_class = ProductORM + + def _to_orm_kwargs(self, domain: ProductDomain, **extra: Any) -> dict[str, Any]: + return { + "odoo_id": domain.odoo_id, + "name": domain.name, + "internal_reference": domain.internal_reference, + "sale_price": domain.sale_price, + "product_type": domain.product_type, + } diff --git a/src/repositories/sale_order_line_repository.py b/src/repositories/sale_order_line_repository.py new file mode 100644 index 0000000..14481b9 --- /dev/null +++ b/src/repositories/sale_order_line_repository.py @@ -0,0 +1,25 @@ +from typing import Any + +from src.db.models import SaleOrderLine as SaleOrderLineORM +from src.domain.models import SaleOrderLine as SaleOrderLineDomain +from src.repositories.base_repository import BaseRepository + + +class SaleOrderLineRepository(BaseRepository[SaleOrderLineORM, SaleOrderLineDomain]): + model_class = SaleOrderLineORM + + def _to_orm_kwargs(self, domain: SaleOrderLineDomain, **extra: Any) -> dict[str, Any]: + for required in ("sale_order_id", "product_id"): + if required not in extra: + raise ValueError( + f"SaleOrderLineRepository.upsert نیاز به {required} (extra) داره." + ) + + return { + "odoo_id": domain.odoo_id, + "sale_order_id": extra["sale_order_id"], + "product_id": extra["product_id"], + "quantity": domain.quantity, + "unit_price": domain.unit_price, + "subtotal": domain.subtotal, + } diff --git a/src/repositories/sale_order_repository.py b/src/repositories/sale_order_repository.py new file mode 100644 index 0000000..88dc1d6 --- /dev/null +++ b/src/repositories/sale_order_repository.py @@ -0,0 +1,24 @@ +from typing import Any + +from src.db.models import SaleOrder as SaleOrderORM +from src.domain.models import SaleOrder as SaleOrderDomain +from src.repositories.base_repository import BaseRepository + + +class SaleOrderRepository(BaseRepository[SaleOrderORM, SaleOrderDomain]): + model_class = SaleOrderORM + + def _to_orm_kwargs(self, domain: SaleOrderDomain, **extra: Any) -> dict[str, Any]: + # customer_id باید از بیرون (Service Layer) پاس داده بشه، چون این + # Repository نمی‌دونه Contact مربوطه تو جدول contacts چه id داخلی‌ای داره. + if "customer_id" not in extra: + raise ValueError("SaleOrderRepository.upsert نیاز به customer_id (extra) داره.") + + return { + "odoo_id": domain.odoo_id, + "order_number": domain.order_number, + "customer_id": extra["customer_id"], + "order_date": domain.order_date, + "state": domain.state, + "total_amount": domain.total_amount, + } diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/__pycache__/__init__.cpython-313.pyc b/src/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3ff9358 Binary files /dev/null and b/src/services/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/services/__pycache__/base_sync_service.cpython-313.pyc b/src/services/__pycache__/base_sync_service.cpython-313.pyc new file mode 100644 index 0000000..e12e757 Binary files /dev/null and b/src/services/__pycache__/base_sync_service.cpython-313.pyc differ diff --git a/src/services/__pycache__/contact_sync_service.cpython-313.pyc b/src/services/__pycache__/contact_sync_service.cpython-313.pyc new file mode 100644 index 0000000..e5f962c Binary files /dev/null and b/src/services/__pycache__/contact_sync_service.cpython-313.pyc differ diff --git a/src/services/__pycache__/product_sync_service.cpython-313.pyc b/src/services/__pycache__/product_sync_service.cpython-313.pyc new file mode 100644 index 0000000..886a543 Binary files /dev/null and b/src/services/__pycache__/product_sync_service.cpython-313.pyc differ diff --git a/src/services/__pycache__/sale_order_line_sync_service.cpython-313.pyc b/src/services/__pycache__/sale_order_line_sync_service.cpython-313.pyc new file mode 100644 index 0000000..af67221 Binary files /dev/null and b/src/services/__pycache__/sale_order_line_sync_service.cpython-313.pyc differ diff --git a/src/services/__pycache__/sale_order_sync_service.cpython-313.pyc b/src/services/__pycache__/sale_order_sync_service.cpython-313.pyc new file mode 100644 index 0000000..74b0522 Binary files /dev/null and b/src/services/__pycache__/sale_order_sync_service.cpython-313.pyc differ diff --git a/src/services/__pycache__/sync_orchestrator.cpython-313.pyc b/src/services/__pycache__/sync_orchestrator.cpython-313.pyc new file mode 100644 index 0000000..574d071 Binary files /dev/null and b/src/services/__pycache__/sync_orchestrator.cpython-313.pyc differ diff --git a/src/services/base_sync_service.py b/src/services/base_sync_service.py new file mode 100644 index 0000000..ac1ac63 --- /dev/null +++ b/src/services/base_sync_service.py @@ -0,0 +1,156 @@ +""" +BaseSyncService قلب مدیریت خطا و Logging پروژه‌ست. + +نکته‌ی مهم فنی: چرا از session.begin_nested() استفاده کردیم؟ +------------------------------------------------------------ +همه‌ی رکوردهای یک Sync تو یک Session/Transaction مشترک پردازش میشن (برای +اینکه سریع‌تر باشه، مجبور نباشیم برای هر رکورد یه Transaction جدا باز کنیم). +اما مشکل اینجاست: اگه رکورد ۳ خطا بده و بخوایم session.rollback() کنیم، +این کل Transaction رو rollback می‌کنه — یعنی رکورد ۱ و ۲ که موفق بودن هم +از بین میرن! + +راه‌حل: SAVEPOINT (با session.begin_nested()). قبل از هر رکورد یک "نقطه‌ی +برگشت کوچیک" باز می‌کنیم. اگه اون رکورد خطا داد، فقط تا همون نقطه برمی‌گردیم، +بدون اینکه رکوردهای قبلی که موفق بودن رو از دست بدیم. این دقیقاً همون +رفتاریه که سناریوی آزمون خواسته: + Record 1 -> Success, Record 2 -> Success, Record 3 -> Error, + Record 4 -> Success, Record 5 -> Success (پردازش متوقف نمیشه) +""" + +from abc import ABC, abstractmethod +from datetime import datetime +import logging +from typing import Any + +from sqlalchemy.orm import Session + +from src.db.models import SyncRun, SyncLog + +logger = logging.getLogger(__name__) + + +class BaseSyncService(ABC): + #: هر Service فرزند این رو ست می‌کنه، مثلاً "contacts_sync" + operation_type: str + + def __init__(self, session: Session): + self.session = session + + # ---------- هر Service فرزند این دو متد رو پیاده‌سازی می‌کنه ---------- + @abstractmethod + def fetch_records(self) -> list[dict[str, Any]]: + """رکوردهای خام رو از Odoo می‌گیره.""" + raise NotImplementedError + + @abstractmethod + def sync_one(self, raw_record: dict[str, Any]) -> bool: + """ + یک رکورد رو Map و در دیتابیس Upsert می‌کنه. + خروجی: True اگه رکورد جدید بود، False اگه آپدیت شد. + """ + raise NotImplementedError + + def get_last_successful_sync_time(self) -> datetime | None: + """ + زمان شروع آخرین اجرای موفق (یا partial_success) همین operation_type + رو برمی‌گردونه. Serviceهای فرزند از این برای Incremental Sync + استفاده می‌کنن -- یعنی به‌جای خوندن کل داده‌ی Odoo، فقط رکوردهایی + که از آخرین Sync موفق به بعد تغییر کردن رو می‌خونن. + اگه قبلاً هیچ Sync موفقی نبوده (اولین اجرا)، None برمی‌گردونه که + یعنی «همه‌چیز رو بخون» (Full Sync). + """ + last_run = ( + self.session.query(SyncRun) + .filter( + SyncRun.operation_type == self.operation_type, + SyncRun.status.in_(["success", "partial_success"]), + ) + .order_by(SyncRun.started_at.desc()) + .first() + ) + return last_run.started_at if last_run else None + + # ---------- orchestration مشترک ---------- + def run(self) -> SyncRun: + logger.info(f"شروع Sync: {self.operation_type}") + + sync_run = SyncRun( + operation_type=self.operation_type, + started_at=datetime.utcnow(), + status="running", + ) + self.session.add(sync_run) + self.session.flush() # الان sync_run.id در دسترسه + + try: + records = self.fetch_records() + except Exception as exc: + logger.error(f"[{self.operation_type}] دریافت داده از Odoo شکست خورد: {exc}") + self._log(sync_run, "ERROR", f"دریافت داده از Odoo شکست خورد: {exc}") + sync_run.status = "failed" + sync_run.finished_at = datetime.utcnow() + self.session.commit() + raise + + sync_run.records_fetched = len(records) + + created = updated = failed = 0 + + try: + for raw_record in records: + record_id = raw_record.get("id", "?") + try: + with self.session.begin_nested(): # SAVEPOINT مخصوص همین رکورد + was_created = self.sync_one(raw_record) + if was_created: + created += 1 + else: + updated += 1 + except Exception as exc: + failed += 1 + logger.warning(f"[{self.operation_type}] خطا در رکورد odoo_id={record_id}: {exc}") + self._log( + sync_run, "ERROR", + f"خطا در پردازش رکورد: {exc}", + record_reference=f"odoo_id={record_id}", + ) + # پردازش رکورد بعدی ادامه پیدا می‌کنه (continue ضمنیه، حلقه ادامه داره) + except KeyboardInterrupt: + # Graceful Shutdown: وقتی Ctrl+C یا سیگنال توقف (SIGTERM از + # docker stop) میاد، به‌جای اینکه بدون هیچ ثبتی بمیریم، وضعیت + # فعلی (چندتا رکورد تا الان موفق/ناموفق بودن) رو ذخیره می‌کنیم + # و sync_run رو با status="cancelled" می‌بندیم -- بعداً اگه + # دوباره اجرا بشه، Idempotency تضمین می‌کنه چیزی خراب/دوتا نشه. + logger.warning(f"[{self.operation_type}] سیگنال توقف دریافت شد؛ بستن تمیز Sync...") + sync_run.records_created = created + sync_run.records_updated = updated + sync_run.records_failed = failed + sync_run.status = "cancelled" + sync_run.finished_at = datetime.utcnow() + self.session.commit() + raise + + sync_run.records_created = created + sync_run.records_updated = updated + sync_run.records_failed = failed + sync_run.status = "success" if failed == 0 else "partial_success" + sync_run.finished_at = datetime.utcnow() + + self.session.commit() + + logger.info( + f"پایان Sync: {self.operation_type} | " + f"fetched={sync_run.records_fetched} created={created} " + f"updated={updated} failed={failed} status={sync_run.status}" + ) + return sync_run + + def _log(self, sync_run: SyncRun, level: str, message: str, record_reference: str | None = None): + self.session.add( + SyncLog( + sync_run_id=sync_run.id, + level=level, + message=message, + record_reference=record_reference, + ) + ) diff --git a/src/services/contact_sync_service.py b/src/services/contact_sync_service.py new file mode 100644 index 0000000..c626f17 --- /dev/null +++ b/src/services/contact_sync_service.py @@ -0,0 +1,25 @@ +from typing import Any + +from src.mappers.contact_mapper import ContactMapper +from src.odoo_client.client import OdooClient +from src.repositories.contact_repository import ContactRepository +from src.services.base_sync_service import BaseSyncService + + +class ContactSyncService(BaseSyncService): + operation_type = "contacts_sync" + + def __init__(self, session, odoo_client: OdooClient): + super().__init__(session) + self.odoo_client = odoo_client + self.repo = ContactRepository(session) + self.mapper = ContactMapper() + + def fetch_records(self) -> list[dict[str, Any]]: + since = self.get_last_successful_sync_time() + return self.odoo_client.fetch_contacts(since=since) + + def sync_one(self, raw_record: dict[str, Any]) -> bool: + domain = self.mapper.to_domain(raw_record) + _, created = self.repo.upsert(domain) + return created diff --git a/src/services/product_sync_service.py b/src/services/product_sync_service.py new file mode 100644 index 0000000..c8b19b8 --- /dev/null +++ b/src/services/product_sync_service.py @@ -0,0 +1,25 @@ +from typing import Any + +from src.mappers.product_mapper import ProductMapper +from src.odoo_client.client import OdooClient +from src.repositories.product_repository import ProductRepository +from src.services.base_sync_service import BaseSyncService + + +class ProductSyncService(BaseSyncService): + operation_type = "products_sync" + + def __init__(self, session, odoo_client: OdooClient): + super().__init__(session) + self.odoo_client = odoo_client + self.repo = ProductRepository(session) + self.mapper = ProductMapper() + + def fetch_records(self) -> list[dict[str, Any]]: + since = self.get_last_successful_sync_time() + return self.odoo_client.fetch_products(since=since) + + def sync_one(self, raw_record: dict[str, Any]) -> bool: + domain = self.mapper.to_domain(raw_record) + _, created = self.repo.upsert(domain) + return created diff --git a/src/services/sale_order_line_sync_service.py b/src/services/sale_order_line_sync_service.py new file mode 100644 index 0000000..b0af5ea --- /dev/null +++ b/src/services/sale_order_line_sync_service.py @@ -0,0 +1,38 @@ +from typing import Any + +from src.mappers.sale_order_line_mapper import SaleOrderLineMapper +from src.odoo_client.client import OdooClient +from src.repositories.product_repository import ProductRepository +from src.repositories.sale_order_line_repository import SaleOrderLineRepository +from src.repositories.sale_order_repository import SaleOrderRepository +from src.services.base_sync_service import BaseSyncService + + +class SaleOrderLineSyncService(BaseSyncService): + operation_type = "sale_order_lines_sync" + + def __init__(self, session, odoo_client: OdooClient): + super().__init__(session) + self.odoo_client = odoo_client + self.repo = SaleOrderLineRepository(session) + self.order_repo = SaleOrderRepository(session) + self.product_repo = ProductRepository(session) + self.mapper = SaleOrderLineMapper() + + def fetch_records(self) -> list[dict[str, Any]]: + since = self.get_last_successful_sync_time() + return self.odoo_client.fetch_sale_order_lines(since=since) + + def sync_one(self, raw_record: dict[str, Any]) -> bool: + domain = self.mapper.to_domain(raw_record) + + order = self.order_repo.get_by_odoo_id(domain.order_odoo_id) + if order is None: + raise ValueError(f"Sale Order با odoo_id={domain.order_odoo_id} پیدا نشد.") + + product = self.product_repo.get_by_odoo_id(domain.product_odoo_id) + if product is None: + raise ValueError(f"Product با odoo_id={domain.product_odoo_id} پیدا نشد.") + + _, created = self.repo.upsert(domain, sale_order_id=order.id, product_id=product.id) + return created diff --git a/src/services/sale_order_sync_service.py b/src/services/sale_order_sync_service.py new file mode 100644 index 0000000..fc3516a --- /dev/null +++ b/src/services/sale_order_sync_service.py @@ -0,0 +1,37 @@ +from typing import Any + +from src.mappers.sale_order_mapper import SaleOrderMapper +from src.odoo_client.client import OdooClient +from src.repositories.contact_repository import ContactRepository +from src.repositories.sale_order_repository import SaleOrderRepository +from src.services.base_sync_service import BaseSyncService + + +class SaleOrderSyncService(BaseSyncService): + operation_type = "sale_orders_sync" + + def __init__(self, session, odoo_client: OdooClient): + super().__init__(session) + self.odoo_client = odoo_client + self.repo = SaleOrderRepository(session) + self.contact_repo = ContactRepository(session) + self.mapper = SaleOrderMapper() + + def fetch_records(self) -> list[dict[str, Any]]: + since = self.get_last_successful_sync_time() + return self.odoo_client.fetch_sale_orders(since=since) + + def sync_one(self, raw_record: dict[str, Any]) -> bool: + domain = self.mapper.to_domain(raw_record) + + customer = self.contact_repo.get_by_odoo_id(domain.customer_odoo_id) + if customer is None: + # این خطا میره تو sync_logs و پردازش رکورد بعدی ادامه پیدا می‌کنه. + # معمولاً یعنی Contactها هنوز sync نشدن -- باید همیشه اول Contacts، + # بعد Sale Orders رو sync کنیم (ترتیب تو sync_orchestrator رعایت شده). + raise ValueError( + f"مشتری با odoo_id={domain.customer_odoo_id} در دیتابیس پیدا نشد." + ) + + _, created = self.repo.upsert(domain, customer_id=customer.id) + return created diff --git a/src/services/sync_orchestrator.py b/src/services/sync_orchestrator.py new file mode 100644 index 0000000..8150fc2 --- /dev/null +++ b/src/services/sync_orchestrator.py @@ -0,0 +1,36 @@ +""" +ترتیب اجرا مهمه: Contacts و Products باید قبل از Sale Orders sync بشن (چون +Sale Order به customer_id نیاز داره)، و Sale Orders باید قبل از Sale Order +Lines sync بشه (چون هر line به یک order نیاز داره). +""" + +from sqlalchemy.orm import Session + +from src.db.models import SyncRun +from src.odoo_client.client import OdooClient +from src.services.contact_sync_service import ContactSyncService +from src.services.product_sync_service import ProductSyncService +from src.services.sale_order_sync_service import SaleOrderSyncService +from src.services.sale_order_line_sync_service import SaleOrderLineSyncService + + +class SyncOrchestrator: + def __init__(self, session: Session, odoo_client: OdooClient): + self.session = session + self.odoo_client = odoo_client + + def run_all(self) -> list[SyncRun]: + service_classes = [ + ContactSyncService, + ProductSyncService, + SaleOrderSyncService, + SaleOrderLineSyncService, + ] + + results: list[SyncRun] = [] + for service_cls in service_classes: + service = service_cls(self.session, self.odoo_client) + sync_run = service.run() + results.append(sync_run) + + return results diff --git a/sync.log b/sync.log new file mode 100644 index 0000000..023982f --- /dev/null +++ b/sync.log @@ -0,0 +1,3 @@ +2026-07-27 00:42:56 | WARNING | src.core.retry | تلاش 1/3 برای connect شکست خورد: [WinError 10061] No connection could be made because the target machine actively refused it — 1.0s صبر می‌کنیم و دوباره امتحان می‌کنیم... +2026-07-27 00:43:01 | WARNING | src.core.retry | تلاش 2/3 برای connect شکست خورد: [WinError 10061] No connection could be made because the target machine actively refused it — 2.0s صبر می‌کنیم و دوباره امتحان می‌کنیم... +2026-07-27 00:43:07 | ERROR | src.core.retry | connect بعد از 3 تلاش شکست خورد: [WinError 10061] No connection could be made because the target machine actively refused it diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-313.pyc b/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c682ffd Binary files /dev/null and b/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc b/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..e14a0ce Binary files /dev/null and b/tests/__pycache__/conftest.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..375700e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,45 @@ +""" +conftest.py فایل مخصوص pytest هست که fixtureهای مشترک بین همه‌ی تست‌ها رو +تعریف می‌کنه، بدون اینکه لازم باشه هر فایل تست جدا importشون کنه. + +از SQLite در حافظه (نه فایل واقعی، نه Postgres واقعی) استفاده می‌کنیم تا: +- تست‌ها خیلی سریع اجرا بشن +- نیازی به Docker/app-db روشن نباشه برای اجرای تست‌ها +- هر تست از صفر (دیتابیس خالی) شروع بشه، بدون تداخل با تست‌های دیگه +""" + +import sys +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from src.db.models import Base + + +@pytest.fixture +def session(): + """یک Session تازه با دیتابیس SQLite خالی در حافظه، برای هر تست جداگانه.""" + engine = create_engine("sqlite:///:memory:") + + # این دو event listener لازمن که SQLite بتونه SAVEPOINT + # (یعنی session.begin_nested() که تو BaseSyncService استفاده کردیم) + # رو درست پشتیبانی کنه. بدون این‌ها، pysqlite رفتار Transaction عجیبی + # داره و تست‌های Error Handling درست کار نمی‌کنن. (این یه محدودیت + # شناخته‌شده‌ی خود درایور pysqlite هست، نه SQLAlchemy یا کد ما.) + @event.listens_for(engine, "connect") + def _set_sqlite_isolation(dbapi_connection, connection_record): + dbapi_connection.isolation_level = None + + @event.listens_for(engine, "begin") + def _emit_begin(conn): + conn.exec_driver_sql("BEGIN") + + Base.metadata.create_all(engine) + session_local = sessionmaker(bind=engine) + db_session = session_local() + yield db_session + db_session.close() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__pycache__/__init__.cpython-313.pyc b/tests/unit/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..940681f Binary files /dev/null and b/tests/unit/__pycache__/__init__.cpython-313.pyc differ diff --git a/tests/unit/__pycache__/test_mappers.cpython-313-pytest-8.3.4.pyc b/tests/unit/__pycache__/test_mappers.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..449f3ab Binary files /dev/null and b/tests/unit/__pycache__/test_mappers.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/unit/__pycache__/test_repositories.cpython-313-pytest-8.3.4.pyc b/tests/unit/__pycache__/test_repositories.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..9a54900 Binary files /dev/null and b/tests/unit/__pycache__/test_repositories.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/unit/__pycache__/test_retry.cpython-313-pytest-8.3.4.pyc b/tests/unit/__pycache__/test_retry.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..67fe5fd Binary files /dev/null and b/tests/unit/__pycache__/test_retry.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/unit/__pycache__/test_sync_services.cpython-313-pytest-8.3.4.pyc b/tests/unit/__pycache__/test_sync_services.cpython-313-pytest-8.3.4.pyc new file mode 100644 index 0000000..8e1f123 Binary files /dev/null and b/tests/unit/__pycache__/test_sync_services.cpython-313-pytest-8.3.4.pyc differ diff --git a/tests/unit/test_mappers.py b/tests/unit/test_mappers.py new file mode 100644 index 0000000..961aec4 --- /dev/null +++ b/tests/unit/test_mappers.py @@ -0,0 +1,71 @@ +""" +تست‌های Mapper: چون Mapperها هیچ وابستگی‌ای به دیتابیس یا Odoo واقعی ندارن +(فقط dict می‌گیرن و Domain Model برمی‌گردونن)، این ساده‌ترین و سریع‌ترین +تست‌های پروژه‌ن. +""" + +from datetime import datetime + +from src.mappers.base_mapper import extract_many2one_id +from src.mappers.contact_mapper import ContactMapper +from src.mappers.product_mapper import ProductMapper +from src.mappers.sale_order_mapper import SaleOrderMapper +from src.mappers.sale_order_line_mapper import SaleOrderLineMapper + + +def test_extract_many2one_id_with_list(): + assert extract_many2one_id([15, "Ali Rezaei"]) == 15 + + +def test_extract_many2one_id_with_false(): + # Odoo فیلد خالی رو False برمی‌گردونه، نه None + assert extract_many2one_id(False) is None + + +def test_contact_mapper_converts_false_fields_to_none(): + raw = {"id": 1, "name": "Ali", "email": "ali@example.com", "phone": False, "mobile": False} + contact = ContactMapper().to_domain(raw) + + assert contact.odoo_id == 1 + assert contact.name == "Ali" + assert contact.email == "ali@example.com" + assert contact.phone is None + assert contact.mobile is None + + +def test_product_mapper_basic_fields(): + raw = {"id": 2, "name": "Mouse", "default_code": "PRD-001", "list_price": 25.0, "type": "consu"} + product = ProductMapper().to_domain(raw) + + assert product.odoo_id == 2 + assert product.internal_reference == "PRD-001" + assert product.sale_price == 25.0 + assert product.product_type == "consu" + + +def test_sale_order_mapper_extracts_partner_id_and_parses_date(): + raw = { + "id": 3, "name": "S00001", + "partner_id": [1, "Ali Rezaei"], + "date_order": "2026-06-01 10:00:00", + "state": "sale", + "amount_total": 125.0, + } + order = SaleOrderMapper().to_domain(raw) + + assert order.customer_odoo_id == 1 # نه [1, "Ali Rezaei"]، فقط عدد + assert order.order_date == datetime(2026, 6, 1, 10, 0, 0) + assert order.total_amount == 125.0 + + +def test_sale_order_line_mapper_extracts_order_and_product_ids(): + raw = { + "id": 4, "order_id": [3, "S00001"], "product_id": [2, "Mouse"], + "product_uom_qty": 2.0, "price_unit": 25.0, "price_subtotal": 50.0, + } + line = SaleOrderLineMapper().to_domain(raw) + + assert line.order_odoo_id == 3 + assert line.product_odoo_id == 2 + assert line.quantity == 2.0 + assert line.subtotal == 50.0 diff --git a/tests/unit/test_repositories.py b/tests/unit/test_repositories.py new file mode 100644 index 0000000..eafa2a5 --- /dev/null +++ b/tests/unit/test_repositories.py @@ -0,0 +1,125 @@ +""" +تست‌های Repository: از fixture `session` (تعریف‌شده تو conftest.py، یک +دیتابیس SQLite خالی در حافظه) استفاده می‌کنن. مهم‌ترین چیزی که اینجا تست +می‌کنیم Idempotency هست: اجرای دوباره‌ی upsert نباید رکورد تکراری بسازه. +""" + +import pytest + +from src.db.models import Contact as ContactORM +from src.domain.models import Contact, Product, SaleOrder, SaleOrderLine +from src.repositories.contact_repository import ContactRepository +from src.repositories.product_repository import ProductRepository +from src.repositories.sale_order_repository import SaleOrderRepository +from src.repositories.sale_order_line_repository import SaleOrderLineRepository + + +def test_contact_repository_creates_new_record(session): + repo = ContactRepository(session) + domain = Contact(odoo_id=1, name="Ali", email="ali@example.com") + + instance, created = repo.upsert(domain) + session.commit() + + assert created is True + assert instance.name == "Ali" + assert instance.odoo_id == 1 + + +def test_contact_repository_upsert_is_idempotent(session): + """این مهم‌ترین تست پروژه‌ست: اجرای دوباره نباید رکورد تکراری بسازه.""" + repo = ContactRepository(session) + + # اجرای اول: باید ساخته بشه + domain_v1 = Contact(odoo_id=1, name="Ali", email="ali@example.com") + _, created_first = repo.upsert(domain_v1) + session.commit() + assert created_first is True + + # اجرای دوم با همون odoo_id ولی اسم عوض‌شده: باید آپدیت بشه نه ساخته بشه + domain_v2 = Contact(odoo_id=1, name="Ali Rezaei (Updated)", email="ali@example.com") + instance, created_second = repo.upsert(domain_v2) + session.commit() + + assert created_second is False + assert instance.name == "Ali Rezaei (Updated)" + + # مهم‌ترین چک: تو کل جدول contacts فقط باید ۱ رکورد باشه، نه ۲ + all_contacts = session.query(ContactORM).all() + assert len(all_contacts) == 1 + + +def test_product_repository_upsert(session): + repo = ProductRepository(session) + domain = Product(odoo_id=5, name="Mouse", internal_reference="PRD-001", sale_price=25.0) + + instance, created = repo.upsert(domain) + session.commit() + + assert created is True + assert instance.sale_price == 25.0 + + +def test_sale_order_repository_requires_customer_id(session): + """ + SaleOrderRepository نمی‌تونه بدون customer_id کار کنه (باید از بیرون + پاس داده بشه). این تست مطمئن میشه این محدودیت درست اعمال شده. + """ + repo = SaleOrderRepository(session) + domain = SaleOrder(odoo_id=10, order_number="S00001", customer_odoo_id=1, total_amount=100.0) + + with pytest.raises(ValueError): + repo.upsert(domain) # customer_id رو عمداً پاس ندادیم + + +def test_sale_order_repository_upsert_with_customer_id(session): + contact_repo = ContactRepository(session) + order_repo = SaleOrderRepository(session) + + customer, _ = contact_repo.upsert(Contact(odoo_id=1, name="Ali")) + session.flush() + + domain = SaleOrder(odoo_id=10, order_number="S00001", customer_odoo_id=1, total_amount=100.0) + instance, created = order_repo.upsert(domain, customer_id=customer.id) + session.commit() + + assert created is True + assert instance.customer_id == customer.id + + +def test_sale_order_line_repository_requires_order_and_product_id(session): + repo = SaleOrderLineRepository(session) + domain = SaleOrderLine(odoo_id=100, order_odoo_id=10, product_odoo_id=5, quantity=2.0) + + with pytest.raises(ValueError): + repo.upsert(domain) # sale_order_id و product_id رو عمداً پاس ندادیم + + +def test_sale_order_line_repository_full_upsert_chain(session): + """ + این تست کل زنجیره رو تست می‌کنه: Contact -> Sale Order -> Sale Order Line، + دقیقاً همون ترتیبی که تو Service Layer واقعی هم رعایت میشه. + """ + contact_repo = ContactRepository(session) + product_repo = ProductRepository(session) + order_repo = SaleOrderRepository(session) + line_repo = SaleOrderLineRepository(session) + + customer, _ = contact_repo.upsert(Contact(odoo_id=1, name="Ali")) + product, _ = product_repo.upsert(Product(odoo_id=5, name="Mouse", sale_price=25.0)) + session.flush() + + order, _ = order_repo.upsert( + SaleOrder(odoo_id=10, order_number="S00001", customer_odoo_id=1, total_amount=50.0), + customer_id=customer.id, + ) + session.flush() + + line_domain = SaleOrderLine(odoo_id=100, order_odoo_id=10, product_odoo_id=5, quantity=2.0, unit_price=25.0, subtotal=50.0) + line, created = line_repo.upsert(line_domain, sale_order_id=order.id, product_id=product.id) + session.commit() + + assert created is True + assert line.sale_order_id == order.id + assert line.product_id == product.id + assert line.subtotal == 50.0 diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py new file mode 100644 index 0000000..5336b42 --- /dev/null +++ b/tests/unit/test_retry.py @@ -0,0 +1,79 @@ +from src.core.retry import retry + + +def test_retry_succeeds_after_transient_failures(): + """تابع ۲ بار شکست می‌خوره و بار سوم موفق میشه؛ retry باید همین رفتار رو تحمل کنه. + از ConnectionError استفاده می‌کنیم (نه ValueError) چون ValueError جزو + NON_RETRYABLE_EXCEPTIONS هست و طبق طراحی هیچ‌وقت retry نمیشه.""" + calls = {"count": 0} + + @retry(times=3, delay=0, backoff=1, exceptions=(ConnectionError,)) + def flaky(): + calls["count"] += 1 + if calls["count"] < 3: + raise ConnectionError("قطعی موقت شبکه") + return "OK" + + result = flaky() + + assert result == "OK" + assert calls["count"] == 3 + + +def test_retry_raises_after_exhausting_all_attempts(): + """اگه بعد از همه‌ی تلاش‌ها بازم شکست بخوره، خطای اصلی باید بالا بره.""" + calls = {"count": 0} + + @retry(times=2, delay=0, backoff=1, exceptions=(ConnectionError,)) + def always_fails(): + calls["count"] += 1 + raise ConnectionError("همیشه شکست می‌خوره") + + try: + always_fails() + assert False, "باید ConnectionError raise می‌شد" + except ConnectionError: + pass + + assert calls["count"] == 2 # دقیقاً به تعداد times تلاش کرد، نه بیشتر + + +def test_retry_does_not_catch_unrelated_exceptions(): + """اگه نوع خطا تو لیست exceptions نباشه (و NON_RETRYABLE هم نباشه)، اصلاً retry نمی‌کنه.""" + calls = {"count": 0} + + @retry(times=3, delay=0, backoff=1, exceptions=(ConnectionError,)) + def raises_runtime_error(): + calls["count"] += 1 + raise RuntimeError("این نوع خطا تو لیست exceptions نیست") + + try: + raises_runtime_error() + assert False, "باید RuntimeError raise می‌شد" + except RuntimeError: + pass + + assert calls["count"] == 1 # فقط یک‌بار تلاش کرد + + +def test_retry_never_retries_logical_errors_even_if_explicitly_listed(): + """ + نکته‌ی مهم طراحی: حتی اگه صراحتاً بگی exceptions=(ValueError,)، چون + ValueError جزو NON_RETRYABLE_EXCEPTIONS هست، اصلاً retry نمیشه -- چون + این‌جور خطاها معمولاً یعنی باگ تو کد، نه مشکل موقتی شبکه، و retry + کردنشون فقط وقت تلف می‌کنه. + """ + calls = {"count": 0} + + @retry(times=3, delay=0, backoff=1, exceptions=(ValueError,)) + def raises_value_error(): + calls["count"] += 1 + raise ValueError("این یه خطای منطقیه، نه شبکه‌ای") + + try: + raises_value_error() + assert False, "باید ValueError raise می‌شد" + except ValueError: + pass + + assert calls["count"] == 1 # فقط یک‌بار تلاش کرد، هیچ retry‌ای انجام نشد diff --git a/tests/unit/test_sync_services.py b/tests/unit/test_sync_services.py new file mode 100644 index 0000000..ad81aef --- /dev/null +++ b/tests/unit/test_sync_services.py @@ -0,0 +1,171 @@ +""" +تست‌های Service Layer با Mock کردن OdooClient — یعنی اصلاً به Odoo واقعی +وصل نمی‌شیم، فقط یک شیء تقلبی (Mock) می‌سازیم که داده‌ی از‌پیش‌مشخص برمی‌گردونه. +این باعث میشه تست‌ها سریع، پایدار (Odoo روشن باشه یا نه فرقی نمی‌کنه) و +قابل‌کنترل (می‌تونیم عمداً داده‌ی خراب بدیم) باشن. +""" + +from unittest.mock import MagicMock + +import pytest + +from src.db.models import SyncLog, SyncRun +from src.services.contact_sync_service import ContactSyncService +from src.services.product_sync_service import ProductSyncService +from src.services.sale_order_sync_service import SaleOrderSyncService + + +def test_contact_sync_creates_new_records(session): + fake_odoo = MagicMock() + fake_odoo.fetch_contacts.return_value = [ + {"id": 1, "name": "Ali", "email": "ali@example.com", "phone": False, "mobile": False}, + {"id": 2, "name": "Sara", "email": "sara@example.com", "phone": False, "mobile": False}, + ] + + service = ContactSyncService(session, fake_odoo) + sync_run = service.run() + + assert sync_run.status == "success" + assert sync_run.records_fetched == 2 + assert sync_run.records_created == 2 + assert sync_run.records_failed == 0 + + +def test_contact_sync_continues_after_one_record_fails(session): + """ + مهم‌ترین تست پروژه از نظر مدیریت خطا: + Record 1 -> موفق، Record 2 -> خراب (فیلد name نداره)، Record 3 -> موفق. + طبق سناریوی آزمون، پردازش نباید بعد از خطای رکورد ۲ متوقف بشه. + """ + fake_odoo = MagicMock() + fake_odoo.fetch_contacts.return_value = [ + {"id": 1, "name": "Ali", "email": "ali@example.com", "phone": False, "mobile": False}, + {"id": 2, "email": "broken@example.com"}, # عمداً فیلد "name" رو حذف کردیم تا خطا بده + {"id": 3, "name": "Reza", "email": "reza@example.com", "phone": False, "mobile": False}, + ] + + service = ContactSyncService(session, fake_odoo) + sync_run = service.run() + + assert sync_run.records_fetched == 3 + assert sync_run.records_created == 2 # رکورد ۱ و ۳ + assert sync_run.records_failed == 1 # رکورد ۲ + assert sync_run.status == "partial_success" + + # خطا باید تو sync_logs هم ثبت شده باشه + logs = session.query(SyncLog).filter_by(sync_run_id=sync_run.id).all() + assert len(logs) == 1 + assert logs[0].level == "ERROR" + assert "odoo_id=2" in logs[0].record_reference + + +def test_contact_sync_is_idempotent_across_two_runs(session): + """اجرای Service دوبار پشت‌سرهم نباید رکورد تکراری بسازه.""" + fake_odoo = MagicMock() + fake_odoo.fetch_contacts.return_value = [ + {"id": 1, "name": "Ali", "email": "ali@example.com", "phone": False, "mobile": False}, + ] + + first_run = ContactSyncService(session, fake_odoo).run() + assert first_run.records_created == 1 + assert first_run.records_updated == 0 + + second_run = ContactSyncService(session, fake_odoo).run() + assert second_run.records_created == 0 + assert second_run.records_updated == 1 + + +def test_product_sync_creates_new_records(session): + fake_odoo = MagicMock() + fake_odoo.fetch_products.return_value = [ + {"id": 1, "name": "Mouse", "default_code": "PRD-001", "list_price": 25.0, "type": "consu"}, + {"id": 2, "name": "Keyboard", "default_code": "PRD-002", "list_price": 75.0, "type": "consu"}, + ] + + sync_run = ProductSyncService(session, fake_odoo).run() + + assert sync_run.status == "success" + assert sync_run.records_created == 2 + assert sync_run.records_failed == 0 + + +def test_sale_order_sync_fails_gracefully_when_customer_missing(): + """ + اگه Sale Order به مشتری‌ای اشاره کنه که هنوز sync نشده، نباید کل برنامه + کرش کنه — باید به‌عنوان یک رکورد ناموفق ثبت بشه و ادامه پیدا کنه. + """ + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from src.db.models import Base + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + local_session = sessionmaker(bind=engine)() + + fake_odoo = MagicMock() + fake_odoo.fetch_sale_orders.return_value = [ + { + "id": 10, "name": "S00001", + "partner_id": [999, "Unknown Customer"], # این مشتری تو دیتابیس ما نیست + "date_order": "2026-06-01 10:00:00", + "state": "sale", "amount_total": 50.0, + }, + ] + + sync_run = SaleOrderSyncService(local_session, fake_odoo).run() + + assert sync_run.records_fetched == 1 + assert sync_run.records_failed == 1 + assert sync_run.records_created == 0 + assert sync_run.status == "partial_success" + + +def test_incremental_sync_passes_last_run_time_on_second_call(session): + """ + اجرای اول باید since=None (یعنی Full Sync) صدا بزنه. اجرای دوم باید + since رو برابر زمان شروع اجرای اول بده -- یعنی Incremental Sync. + """ + fake_odoo = MagicMock() + fake_odoo.fetch_contacts.return_value = [ + {"id": 1, "name": "Ali", "email": "ali@example.com", "phone": False, "mobile": False}, + ] + + first_run = ContactSyncService(session, fake_odoo).run() + fake_odoo.fetch_contacts.assert_called_with(since=None) + + ContactSyncService(session, fake_odoo).run() + _, kwargs = fake_odoo.fetch_contacts.call_args + assert kwargs["since"] == first_run.started_at + + +def test_graceful_shutdown_marks_sync_run_as_cancelled(session): + """ + اگه وسط پردازش یه KeyboardInterrupt (شبیه‌ساز Ctrl+C یا SIGTERM) بیاد، + نباید بدون هیچ ثبتی بمیره -- باید sync_run با status='cancelled' و + آماری که تا همون‌جا جمع شده ذخیره بشه. + """ + fake_odoo = MagicMock() + fake_odoo.fetch_contacts.return_value = [ + {"id": 1, "name": "Ali", "email": "ali@example.com", "phone": False, "mobile": False}, + {"id": 2, "name": "Sara", "email": "sara@example.com", "phone": False, "mobile": False}, + ] + + service = ContactSyncService(session, fake_odoo) + + original_sync_one = service.sync_one + call_count = {"n": 0} + + def flaky_sync_one(raw_record): + call_count["n"] += 1 + if call_count["n"] == 2: + raise KeyboardInterrupt() + return original_sync_one(raw_record) + + service.sync_one = flaky_sync_one + + with pytest.raises(KeyboardInterrupt): + service.run() + + saved_run = session.query(SyncRun).filter_by(operation_type="contacts_sync").first() + assert saved_run.status == "cancelled" + assert saved_run.records_created == 1 # رکورد ۱ قبل از وقفه موفق شده بود