Zhou #36
@@ -0,0 +1,138 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -0,0 +1,149 @@
|
||||
# OdooshCN Lab Environments
|
||||
|
||||
Syncs the users of this Odoo instance to an OdooshCN platform and gives every user a
|
||||
one-click entry to their own lab environment.
|
||||
|
||||
## Install
|
||||
|
||||
1. Add the parent folder to `addons_path`:
|
||||
|
||||
```
|
||||
addons_path = ...,D:\odoo_project\odoo18-2\odoo\custom_project\school-python
|
||||
```
|
||||
|
||||
2. Restart Odoo, update the app list and install **OdooshCN Lab Environments**.
|
||||
|
||||
## Configure
|
||||
|
||||
### 1. Create an access token on OdooshCN
|
||||
|
||||
Sign in as a super administrator, open the user menu in the top right corner and choose
|
||||
**Access tokens**, then create one:
|
||||
|
||||
| Field | Suggested value |
|
||||
|---|---|
|
||||
| Name | Odoo course system |
|
||||
| Source | `school` (synced users are recorded under this source) |
|
||||
| Organisation | where the users should land |
|
||||
| Scopes | tick all four |
|
||||
| Maximum grantable role | Organisation manager, if any user needs that role |
|
||||
| Instances per user | 1 |
|
||||
| Allow deleting users | on, if deleting a user here should delete the platform account |
|
||||
| Allow other organisations / platform administrator | off |
|
||||
|
||||
The token is shown once, so copy it immediately. Scopes can be edited later without
|
||||
changing the token value.
|
||||
|
||||
### 2. Fill it in here
|
||||
|
||||
Settings > General Settings > OdooshCN Lab Environments:
|
||||
|
||||
1. Turn on **Enable sync**.
|
||||
2. Enter the platform URL and the token, then press **Test connection**. It validates the
|
||||
URL and token only, reports the organisation, the scopes and the limits, and warns when
|
||||
a scope is missing.
|
||||
For local testing use `http://127.0.0.1:port` rather than `localhost`: on Windows
|
||||
`localhost` resolves to IPv6 first while WSL2 and Docker only forward IPv4, and every
|
||||
request would wait out a 20 second timeout. The module rewrites localhost anyway, but
|
||||
an explicit address is safer.
|
||||
3. Adjust the environment defaults if needed and save.
|
||||
|
||||
That is all. There is nothing to configure per user.
|
||||
|
||||
## Daily use
|
||||
|
||||
New users are synced automatically. Everything the platform knows about a user sits on the
|
||||
**Lab Environment** tab of the user form:
|
||||
|
||||
| Field | Meaning | Default |
|
||||
|---|---|---|
|
||||
| Sync to lab platform | Master switch for this user | On |
|
||||
| Platform username override | Empty derives it from the login | Empty |
|
||||
| Organisation | Platform organisation slug | Empty (default) |
|
||||
| Platform role | Regular user or platform administrator | Regular user |
|
||||
| Role in organisation | Tester / Developer / Organisation manager | Developer |
|
||||
| Web Shell, Enterprise edition, Auto-provision | Switches | On / Off / On |
|
||||
| Instance limit, Extra database limit | 0 means unlimited | 1 / 2 |
|
||||
| Platform menus | Overview / Git / AI / Backups | Off / Off / On / Off |
|
||||
|
||||
Select several users in the list and use the **Actions** menu to sync, enable or disable
|
||||
them in bulk.
|
||||
|
||||
Users open their environment from the **Lab Environment** menu. The platform console - the
|
||||
instance list and the instance detail page - is embedded in a frame on that same page, so
|
||||
nobody leaves the course system, with an *Open in a new tab* button for a full window. The
|
||||
instance's own Odoo always opens in a new tab: nesting a whole Odoo inside another one
|
||||
stacks three navigation bars and two sidebars.
|
||||
|
||||
For the frame to work the platform has to allow being embedded by this Odoo. Its console
|
||||
answers `X-Frame-Options: SAMEORIGIN` by default and the frame stays blank; the platform
|
||||
administrator drops a file `console-frame-odoo.inc` into `<data dir>/nginx/` naming this
|
||||
server, then reloads the platform's web container:
|
||||
|
||||
```
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' http://school.example.com:8069" always;
|
||||
```
|
||||
|
||||
The value has to match what users actually have in the address bar, scheme and port
|
||||
included: `http://localhost:8069` does not cover `http://the-machine-name:8069`.
|
||||
|
||||
## What happens when
|
||||
|
||||
| In Odoo | On the platform |
|
||||
|---|---|
|
||||
| Create a user | Account created, sync switch turned on, environment provisioned |
|
||||
| Update a user | Pushed when the switch is on, ignored when it is off |
|
||||
| Archive a user | Account deactivated, instances stopped, nothing deleted |
|
||||
| Restore an archived user | Account activated again |
|
||||
| Delete a user | Account deleted; the instances move to the platform recycle bin, where the data stays recoverable for the retention period |
|
||||
|
||||
Deleting requires the access token to allow it. Without that permission the platform
|
||||
refuses and the queue row shows `delete_not_allowed`; the account is then only deactivated.
|
||||
|
||||
Portal users and built-in Odoo accounts (OdooBot, the public user, the portal template and
|
||||
the new-user default template) are never synced.
|
||||
|
||||
## How syncing works
|
||||
|
||||
The hooks only write a row into `odoosh.sync.log`; **no HTTP happens inside a user
|
||||
request**. A dedicated thread pushes right after the transaction commits, using its own
|
||||
database connection, so a failure there can never affect what was already saved.
|
||||
|
||||
Three paths back each other up:
|
||||
|
||||
| Path | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| Background thread | Right after commit | The user barely notices a delay |
|
||||
| Queue cron | Every minute | Takes over when the thread was killed with the worker, and drives the retry back-off |
|
||||
| User scan | Inside that same cron, every 30 minutes | Walks users changed since the last scan and compares fingerprints |
|
||||
|
||||
The scan has no cron of its own: it is cheap, but Odoo's cron pool is shared by the whole
|
||||
database (`max_cron_threads`, 2 by default), so a slot costs more than the scan does. Set the
|
||||
system parameter `odoosh.scan_interval_minutes` to change the interval, or to `0` to turn the
|
||||
scan off and rely on the hooks alone.
|
||||
|
||||
Cost control: only `login`, `name`, `active`, the sync switch and the platform permission
|
||||
fields trigger a push, so changing an avatar or a preference does not; the scan reads at
|
||||
most 200 users per round through an index; identical content is never pushed twice.
|
||||
|
||||
A daily reconciliation sends the full list of users so the platform can deactivate accounts
|
||||
that disappeared. An empty local list is skipped, and the platform refuses a list that
|
||||
would deactivate an implausible share of the accounts.
|
||||
|
||||
## Security
|
||||
|
||||
* The token is stored in a system parameter, readable by system administrators only, and
|
||||
the settings page shows only a mask plus the last four characters.
|
||||
* The identity sent to the platform always comes from the current session, never from a
|
||||
request parameter. Otherwise a user could edit a URL and land in somebody else's
|
||||
environment.
|
||||
* Entry URLs are short lived and single use: they are redirected to immediately, never
|
||||
stored and never logged.
|
||||
|
||||
## Adjusting the code
|
||||
|
||||
Everything a user gets is a field, so day-to-day changes need no code. If the mapping
|
||||
itself has to change, it lives in `models/res_users.py`:
|
||||
|
||||
* `_odoosh_payload()` builds the user payload from the fields.
|
||||
* `_odoosh_env_spec()` builds the environment specification.
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
{
|
||||
'name': 'OdooshCN Lab Environments',
|
||||
'category': 'Productivity',
|
||||
'version': '18.0.2.3.0',
|
||||
'sequence': 20,
|
||||
'summary': "Sync users to the OdooshCN platform and let them open their own lab environment in one click",
|
||||
'description': """
|
||||
OdooshCN Lab Environments
|
||||
=========================
|
||||
|
||||
Syncs the users of this system to an OdooshCN platform and gives every user a
|
||||
one-click entry to their own lab environment, without a second set of credentials.
|
||||
|
||||
Features
|
||||
--------
|
||||
* **User sync** - creating, updating, archiving or deleting a user is pushed to
|
||||
OdooshCN asynchronously. The hooks only write to a local queue, so the platform
|
||||
being slow or down never blocks user management here.
|
||||
* **Per-user permissions** - the platform role, organisation, quotas, Web Shell,
|
||||
enterprise edition and menu visibility are plain fields on the user form.
|
||||
Change a field, it is synced.
|
||||
* **One-click entry** - a menu entry opens the user's own lab environment with no
|
||||
extra login.
|
||||
* **Environment board** - each user sees the name, status and version of their
|
||||
own environments, read live from the platform.
|
||||
* **Sync queue** - administrators can inspect every push, its payload, its result
|
||||
and retry the failed ones.
|
||||
|
||||
Rules
|
||||
-----
|
||||
1. Once the platform URL and access token are configured, a new user is synced
|
||||
immediately and their sync switch is turned on automatically.
|
||||
2. Updating a user syncs when the switch is on, and does nothing when it is off.
|
||||
3. Archiving deactivates the platform account; deleting deletes it; restoring an
|
||||
archived user activates it again.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
Settings > General Settings > OdooshCN Lab Environments: fill in the platform URL
|
||||
and the access token, then press *Test connection*.
|
||||
""",
|
||||
'author': 'Charles',
|
||||
'website': '',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['base', 'base_setup', 'web'],
|
||||
'data': [
|
||||
'security/odoosh_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_cron_data.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/odoosh_sync_log_views.xml',
|
||||
'views/odoosh_environment_views.xml',
|
||||
'views/odoosh_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'odoosh_connector/static/src/js/odoosh_embed.js',
|
||||
'odoosh_connector/static/src/xml/odoosh_embed.xml',
|
||||
'odoosh_connector/static/src/scss/odoosh.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import main
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""User-facing entry points.
|
||||
|
||||
Hard rule: the identity sent to OdooshCN always comes from request.env.user, never from a
|
||||
request parameter. Otherwise a user could edit the URL and land in somebody else's
|
||||
environment. No line in this file reads an identity from kw.
|
||||
|
||||
The access token is read on the server only (inside odoosh.client) and never reaches the
|
||||
browser.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.http import request
|
||||
|
||||
from ..models.odoosh_client import OdooshError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OdooshPortal(http.Controller):
|
||||
|
||||
def _guard(self):
|
||||
"""Whether the current user may use a lab environment."""
|
||||
user = request.env.user
|
||||
if not user or user._is_public():
|
||||
return _("Please sign in first.")
|
||||
if not user.sudo()._odoosh_in_scope():
|
||||
return _("Your account is not set up for lab environments. Ask your administrator.")
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ redirect entry
|
||||
@http.route('/odoosh/go', type='http', auth='user', website=False)
|
||||
def go(self, target='console', env=None, **kw):
|
||||
"""Exchange for a passwordless URL and redirect. target=console opens the console,
|
||||
target=odoo goes straight into the instance.
|
||||
|
||||
`env` is only an environment name, not an identity; the platform still checks that
|
||||
the environment belongs to the current user."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return request.render('odoosh_connector.entry_error', {'message': error})
|
||||
uid = str(request.env.user.id) # identity comes from the session, only ever from there
|
||||
try:
|
||||
result = request.env['odoosh.client'].sudo().handoff(
|
||||
uid, target='odoo' if target == 'odoo' else 'console', env=env)
|
||||
except OdooshError as err:
|
||||
_logger.warning("OdooshCN handoff failed for user#%s: %s", uid, err)
|
||||
return request.render('odoosh_connector.entry_error', {
|
||||
'message': _("Cannot open the lab environment: %s", err.message),
|
||||
'code': err.code,
|
||||
})
|
||||
return request.redirect(result['url'], local=False)
|
||||
|
||||
# ------------------------------------------------------------------ JSON for the client action
|
||||
@http.route('/odoosh/my/envs', type='json', auth='user')
|
||||
def my_envs(self, **kw):
|
||||
"""The current user's environments, for the board in the menu."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return {'error': error, 'envs': []}
|
||||
try:
|
||||
data = request.env['odoosh.client'].sudo().list_envs(str(request.env.user.id))
|
||||
except OdooshError as err:
|
||||
return {'error': err.message, 'code': err.code, 'envs': []}
|
||||
return {'envs': data.get('envs', []), 'username': data.get('username')}
|
||||
|
||||
@http.route('/odoosh/my/url', type='json', auth='user')
|
||||
def my_url(self, target='console', env=None, **kw):
|
||||
"""Exchange for a passwordless URL for the browser to open right away."""
|
||||
error = self._guard()
|
||||
if error:
|
||||
return {'error': error}
|
||||
try:
|
||||
result = request.env['odoosh.client'].sudo().handoff(
|
||||
str(request.env.user.id), target='odoo' if target == 'odoo' else 'console', env=env)
|
||||
except OdooshError as err:
|
||||
return {'error': err.message, 'code': err.code}
|
||||
return {'url': result['url'], 'expires_in': result.get('expires_in')}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Upgrading from 18.0.2.0.0: the per-minute queue cron and the 5-minute scan cron are
|
||||
merged into cron_odoosh_tick below. Matched by code rather than by XML id, so a fresh
|
||||
install stays quiet (a missing XML id logs a warning and a traceback), and restricted
|
||||
to this model so no other module's cron can be caught by the generic code strings. -->
|
||||
<delete model="ir.cron"
|
||||
search="[('model_id.model', '=', 'odoosh.sync.log'),
|
||||
('code', 'in', ['model.cron_process()', 'model.cron_scan()']),
|
||||
('active', 'in', [True, False])]"/>
|
||||
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- The only frequent cron. Every minute it drains the queue: one indexed query on a
|
||||
table that is normally empty, so an idle tick costs nothing worth measuring.
|
||||
Every `odoosh.scan_interval_minutes` minutes (30 by default, 0 disables it) the
|
||||
same tick walks the users first.
|
||||
|
||||
Why one cron instead of two: the work is tiny either way, but Odoo's cron pool is
|
||||
shared by the whole database (max_cron_threads, 2 by default), and it is the slot
|
||||
held every minute - not the CPU - that competes with other modules. -->
|
||||
<record id="cron_odoosh_tick" model="ir.cron">
|
||||
<field name="name">OdooshCN: sync queue and user scan</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_tick()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Daily: send the full list of users so the platform can deactivate the ones that
|
||||
disappeared. An empty local list is skipped; the platform guards the ratio too. -->
|
||||
<record id="cron_odoosh_reconcile" model="ir.cron">
|
||||
<field name="name">OdooshCN: daily reconciliation</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.cron_reconcile()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
|
||||
</odoo>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import odoosh_client
|
||||
from . import odoosh_sync_log
|
||||
from . import res_users
|
||||
from . import res_config_settings
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""The single place that talks to the OdooshCN platform.
|
||||
|
||||
Keeping every outbound request here means retry classification, timeouts and error
|
||||
decoding have one implementation, and the access token is read in this file only.
|
||||
|
||||
Errors come in two kinds, which decide whether the queue retries or gives up:
|
||||
* OdooshRetryable - network trouble, rate limiting, platform 5xx. Worth retrying.
|
||||
* OdooshPermanent - bad parameters, name already taken, beyond the token limits.
|
||||
Retrying cannot help; a human has to look at it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from odoo import _, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT = 20 # seconds; syncing runs in a background queue, never in a user request
|
||||
PARAM_URL = 'odoosh.base_url'
|
||||
PARAM_TOKEN = 'odoosh.token'
|
||||
|
||||
|
||||
class OdooshError(Exception):
|
||||
"""A call to OdooshCN failed. `code` is the platform's stable error code."""
|
||||
|
||||
def __init__(self, message, code='unknown', status=None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
def __str__(self):
|
||||
return '[%s] %s' % (self.code, self.message)
|
||||
|
||||
|
||||
class OdooshRetryable(OdooshError):
|
||||
"""Temporary failure, worth retrying."""
|
||||
|
||||
|
||||
class OdooshPermanent(OdooshError):
|
||||
"""The caller's own problem; retrying will not help."""
|
||||
|
||||
|
||||
class OdooshClient(models.AbstractModel):
|
||||
_name = 'odoosh.client'
|
||||
_description = 'OdooshCN API Client'
|
||||
|
||||
# ------------------------------------------------------------------ configuration
|
||||
def _config(self):
|
||||
"""Read the platform URL and the token. Read here only, and always as sudo:
|
||||
regular users have no access to system parameters."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
base = (icp.get_param(PARAM_URL) or '').strip().rstrip('/')
|
||||
token = (icp.get_param(PARAM_TOKEN) or '').strip()
|
||||
if not base or not token:
|
||||
raise OdooshPermanent(
|
||||
_("The OdooshCN platform URL or access token is not configured yet. "
|
||||
"Go to Settings > General Settings > OdooshCN Lab Environments."),
|
||||
code='not_configured')
|
||||
return self._prefer_ipv4(base), token
|
||||
|
||||
@staticmethod
|
||||
def _prefer_ipv4(base):
|
||||
"""Local development trap on Windows: `localhost` resolves to the IPv6 address
|
||||
::1 first, while WSL2 and Docker only forward IPv4. urllib has no happy-eyeballs
|
||||
fallback, so it waits out the full timeout (20s) on every single request.
|
||||
Swapping localhost for 127.0.0.1 avoids it. Real deployments use a domain name."""
|
||||
parts = urllib.parse.urlsplit(base)
|
||||
if parts.hostname and parts.hostname.lower() == 'localhost':
|
||||
netloc = '127.0.0.1' + (':%s' % parts.port if parts.port else '')
|
||||
return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||
return base
|
||||
|
||||
def _enabled(self):
|
||||
return self.env['ir.config_parameter'].sudo().get_param('odoosh.enabled') in ('True', 'true', '1', True)
|
||||
|
||||
# ------------------------------------------------------------------ request
|
||||
def _request(self, method, path, payload=None):
|
||||
"""Send one request and return the decoded body.
|
||||
Raises OdooshRetryable / OdooshPermanent on failure."""
|
||||
base, token = self._config()
|
||||
url = '%s%s' % (base, path)
|
||||
data = None
|
||||
headers = {'Authorization': 'Bearer %s' % token, 'Accept': 'application/json'}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||
return json.loads(resp.read().decode('utf-8') or '{}')
|
||||
except urllib.error.HTTPError as err:
|
||||
raise self._http_error(err, method, path)
|
||||
except urllib.error.URLError as err:
|
||||
# DNS failure, refused connection, timeout: the platform may just be restarting
|
||||
raise OdooshRetryable(_("Cannot reach OdooshCN (%(url)s): %(reason)s",
|
||||
url=base, reason=err.reason), code='unreachable')
|
||||
except json.JSONDecodeError:
|
||||
raise OdooshRetryable(_("OdooshCN returned a body that is not valid JSON"), code='bad_response')
|
||||
|
||||
def _http_error(self, err, method, path):
|
||||
"""Turn an HTTP error into a coded exception and decide whether it is worth retrying."""
|
||||
raw = ''
|
||||
try:
|
||||
raw = err.read().decode('utf-8')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
code, detail = 'http_%s' % err.code, raw or err.reason
|
||||
try:
|
||||
parsed = json.loads(raw or '{}')
|
||||
code = parsed.get('code') or code
|
||||
d = parsed.get('detail')
|
||||
if isinstance(d, list): # 422 validation errors come as a list
|
||||
d = '; '.join(str(x.get('msg') or x) for x in d)
|
||||
detail = d or detail
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
_logger.warning("OdooshCN %s %s -> %s %s", method, path, err.code, detail)
|
||||
exc = OdooshRetryable if err.code in (408, 429) or err.code >= 500 else OdooshPermanent
|
||||
return exc(detail, code=code, status=err.code)
|
||||
|
||||
# ------------------------------------------------------------------ endpoints
|
||||
def ping(self):
|
||||
"""Connectivity probe: validates URL and token only, no side effect.
|
||||
Returns the token's source, organisation, scopes and limits."""
|
||||
return self._request('GET', '/api/ext/ping')
|
||||
|
||||
def upsert_user(self, payload):
|
||||
"""Create or update a user, keyed by external_id and idempotent.
|
||||
`payload['provision']` additionally asks for a lab environment."""
|
||||
return self._request('POST', '/api/ext/users', payload)
|
||||
|
||||
def deactivate_user(self, external_id, stop_envs=True):
|
||||
"""Deactivate the platform account. Nothing is deleted."""
|
||||
return self._request('POST', '/api/ext/users/deactivate',
|
||||
{'external_id': str(external_id), 'stop_envs': stop_envs})
|
||||
|
||||
def delete_user(self, external_id, purge_envs=False):
|
||||
"""Delete the platform account for good. Requires the token to allow deletion.
|
||||
The user's environments go to the platform recycle bin unless purge_envs is set."""
|
||||
return self._request('POST', '/api/ext/users/delete',
|
||||
{'external_id': str(external_id), 'purge_envs': purge_envs})
|
||||
|
||||
def reconcile(self, external_ids, stop_envs=True, confirm=False):
|
||||
"""Full reconciliation: accounts of this source that are not in the list get
|
||||
deactivated. The platform refuses an obviously incomplete list unless confirmed."""
|
||||
return self._request('POST', '/api/ext/users/reconcile',
|
||||
{'external_ids': [str(x) for x in external_ids],
|
||||
'stop_envs': stop_envs, 'confirm': confirm})
|
||||
|
||||
def list_envs(self, uid):
|
||||
"""List the lab environments of one user."""
|
||||
return self._request('GET', '/api/ext/envs?uid=%s' % urllib.parse.quote(str(uid)))
|
||||
|
||||
def create_env(self, uid, spec):
|
||||
"""Provision one lab environment for a user."""
|
||||
body = dict(spec or {})
|
||||
body['uid'] = str(uid)
|
||||
return self._request('POST', '/api/ext/envs', body)
|
||||
|
||||
def handoff(self, uid, target='console', env=None):
|
||||
"""Exchange for a passwordless entry URL: {'url': ..., 'expires_in': ...}.
|
||||
|
||||
The URL is short lived and single use: redirect to it straight away, never
|
||||
store it and never log it."""
|
||||
body = {'uid': str(uid), 'target': target}
|
||||
if env:
|
||||
body['env'] = env
|
||||
return self._request('POST', '/api/ext/handoff', body)
|
||||
@@ -0,0 +1,458 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sync queue: user changes land in this table and are pushed to OdooshCN asynchronously.
|
||||
|
||||
Why the hooks do not call the API directly: that would make user management here depend
|
||||
on the platform being up. Writing a queue row is a local insert and never fails.
|
||||
|
||||
Three paths, each a fallback for the previous one:
|
||||
* kick_async - a dedicated thread right after the transaction commits, so the user
|
||||
barely notices any delay.
|
||||
* cron_tick - every minute, in case the thread was killed with the worker. It is also
|
||||
the only thing that drives the retry back-off: nothing else comes back
|
||||
for a row whose next_retry_at is four hours away.
|
||||
* cron_scan - folded into that same tick, at most every SCAN_INTERVAL_DEFAULT minutes.
|
||||
Walks users by write_date and compares a payload fingerprint, catching
|
||||
anything the hooks missed (changes made outside the ORM, or made while
|
||||
the module was disabled).
|
||||
|
||||
The scan shares the queue cron's slot instead of owning one. Both jobs are cheap in
|
||||
themselves, but Odoo's cron pool is shared by the whole database (max_cron_threads, 2 by
|
||||
default), so a slot taken every few minutes costs more than the work does.
|
||||
|
||||
Retry policy: temporary failures back off 1, 5, 15, 60 and 240 minutes, then the row is
|
||||
marked failed and waits for a human. Permanent failures are not retried at all.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import SUPERUSER_ID, _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.modules.registry import Registry
|
||||
|
||||
from .odoosh_client import OdooshPermanent, OdooshRetryable
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Minutes to wait after the n-th failure; once exhausted the row is marked failed
|
||||
BACKOFF_MINUTES = [1, 5, 15, 60, 240]
|
||||
BATCH_SIZE = 50 # rows processed per queue round
|
||||
SCAN_BATCH = 200 # users inspected per scan round
|
||||
SCAN_INTERVAL_DEFAULT = 30 # minutes between two scans; the parameter set to 0 disables it
|
||||
PARAM_LAST_SCAN = 'odoosh.last_scan_at' # cursor: user write_date reached by the scan
|
||||
PARAM_SCAN_RUN = 'odoosh.last_scan_run' # when the scan last ran, to space the runs out
|
||||
PARAM_SCAN_INTERVAL = 'odoosh.scan_interval_minutes'
|
||||
|
||||
# At most one push thread per database: a second request finding the lock taken just
|
||||
# returns, because the running thread re-queries the queue until it is empty.
|
||||
_workers = {}
|
||||
_workers_guard = threading.Lock()
|
||||
|
||||
|
||||
class OdooshSyncLog(models.Model):
|
||||
_name = 'odoosh.sync.log'
|
||||
_description = 'OdooshCN Sync Queue'
|
||||
_order = 'id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
# ondelete must be `set null`: when the Odoo user is deleted this row still has to
|
||||
# travel to the platform to delete the account there.
|
||||
user_id = fields.Many2one('res.users', string='User', ondelete='set null', index=True)
|
||||
login = fields.Char(string='Login', help="Kept so the row still identifies someone after the user is deleted")
|
||||
external_id = fields.Char(string='External ID', required=True, index=True,
|
||||
help="The stable key sent to OdooshCN, i.e. the user ID in this system")
|
||||
operation = fields.Selection([
|
||||
('upsert', 'Create / Update'),
|
||||
('deactivate', 'Deactivate'),
|
||||
('delete', 'Delete'),
|
||||
], string='Operation', required=True, default='upsert')
|
||||
state = fields.Selection([
|
||||
('pending', 'Pending'),
|
||||
('done', 'Done'),
|
||||
('skipped', 'Skipped (no change)'),
|
||||
('failed', 'Failed'),
|
||||
('cancelled', 'Cancelled'),
|
||||
], string='Status', default='pending', required=True, index=True)
|
||||
force = fields.Boolean(string='Force push',
|
||||
help="Ignore the payload fingerprint and push even when nothing changed")
|
||||
attempts = fields.Integer(string='Attempts', default=0)
|
||||
next_retry_at = fields.Datetime(string='Next retry', index=True)
|
||||
last_error = fields.Text(string='Last error')
|
||||
error_code = fields.Char(string='Error code',
|
||||
help="The platform's stable error code, used to decide retry versus manual handling")
|
||||
payload_preview = fields.Text(string='Payload', help="What was sent last time; contains no secrets")
|
||||
result = fields.Text(string='Response')
|
||||
synced_at = fields.Datetime(string='Completed on')
|
||||
display_name = fields.Char(compute='_compute_display_name')
|
||||
|
||||
@api.depends('login', 'operation', 'state')
|
||||
def _compute_display_name(self):
|
||||
ops = dict(self._fields['operation'].selection)
|
||||
for rec in self:
|
||||
rec.display_name = '%s - %s' % (rec.login or _('Unknown user'), ops.get(rec.operation, rec.operation))
|
||||
|
||||
# ------------------------------------------------------------------ enqueue
|
||||
@api.model
|
||||
def enqueue(self, users, operation='upsert', force=False):
|
||||
"""Queue a batch of users. An existing pending row for the same user and operation
|
||||
is reused (back-off reset, latest data resent) so renaming twice does not pile up
|
||||
rows. One query for the whole batch, never one per user."""
|
||||
users = users.sudo()
|
||||
if not users:
|
||||
return self.browse()
|
||||
pending = self.sudo().search([
|
||||
('user_id', 'in', users.ids),
|
||||
('operation', '=', operation),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
by_user = {p.user_id.id: p for p in pending}
|
||||
if pending:
|
||||
vals = {'next_retry_at': False, 'attempts': 0}
|
||||
if force:
|
||||
vals['force'] = True
|
||||
pending.write(vals)
|
||||
new_vals = [{
|
||||
'user_id': u.id,
|
||||
'login': u.login,
|
||||
'external_id': str(u.id),
|
||||
'operation': operation,
|
||||
'state': 'pending',
|
||||
'force': force,
|
||||
} for u in users if u.id not in by_user]
|
||||
created = self.sudo().create(new_vals) if new_vals else self.browse()
|
||||
return pending | created
|
||||
|
||||
@api.model
|
||||
def enqueue_delete(self, users):
|
||||
"""Deleting an Odoo user: record the identity now, because after `unlink` the
|
||||
user record is gone and the row has to survive on its own."""
|
||||
users = users.sudo()
|
||||
if not users:
|
||||
return self.browse()
|
||||
return self.sudo().create([{
|
||||
'user_id': u.id,
|
||||
'login': u.login,
|
||||
'external_id': str(u.id),
|
||||
'operation': 'delete',
|
||||
'state': 'pending',
|
||||
} for u in users])
|
||||
|
||||
# ------------------------------------------------------------------ background thread
|
||||
@api.model
|
||||
def kick_async(self, dbname=None, scan_first=False):
|
||||
"""Called after commit: drain the queue in a dedicated thread. One thread per
|
||||
database. `scan_first` runs a user scan first, used after the settings change.
|
||||
|
||||
The thread owns its cursor and environment, fully isolated from the request that
|
||||
started it; anything it raises is logged and cannot affect committed data."""
|
||||
dbname = dbname or self.env.cr.dbname
|
||||
with _workers_guard:
|
||||
lock = _workers.setdefault(dbname, threading.Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
return False # a thread is already running and will pick up the new rows
|
||||
|
||||
def run():
|
||||
threading.current_thread().dbname = dbname # makes the log lines carry the db name
|
||||
try:
|
||||
registry = Registry(dbname)
|
||||
if scan_first:
|
||||
with registry.cursor() as cr:
|
||||
api.Environment(cr, SUPERUSER_ID, {})['odoosh.sync.log'].cron_scan(kick=False)
|
||||
rounds = 0
|
||||
while rounds < 20: # guard: hand back to the cron if rows keep pouring in
|
||||
rounds += 1
|
||||
with registry.cursor() as cr:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
n = env['odoosh.sync.log'].cron_process()
|
||||
if n == 0:
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN push thread died (the cron will take over)")
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
threading.Thread(target=run, name='odoosh-sync-%s' % dbname, daemon=True).start()
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ the cron
|
||||
@api.model
|
||||
def cron_tick(self):
|
||||
"""The module's only frequent cron: drain the queue, and fold the user scan into the
|
||||
same run every `odoosh.scan_interval_minutes` minutes (0 turns the scan off).
|
||||
|
||||
Scanning first and pushing afterwards means whatever the scan finds leaves in this
|
||||
tick instead of waiting for the next one."""
|
||||
if not self.env['odoosh.client']._enabled():
|
||||
return 0
|
||||
if self._scan_due():
|
||||
self.cron_scan(kick=False) # kick_async would only duplicate the push below
|
||||
return self.cron_process()
|
||||
|
||||
@api.model
|
||||
def _scan_due(self):
|
||||
"""Whether this tick also scans. The timestamp is written before the scan runs, so a
|
||||
scan that crashes waits for the next interval instead of retrying every minute."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
try:
|
||||
minutes = int(icp.get_param(PARAM_SCAN_INTERVAL) or SCAN_INTERVAL_DEFAULT)
|
||||
except (TypeError, ValueError):
|
||||
minutes = SCAN_INTERVAL_DEFAULT
|
||||
if minutes <= 0:
|
||||
return False
|
||||
now = fields.Datetime.now()
|
||||
last = icp.get_param(PARAM_SCAN_RUN)
|
||||
if last:
|
||||
try:
|
||||
if now - fields.Datetime.to_datetime(last) < timedelta(minutes=minutes):
|
||||
return False
|
||||
except ValueError:
|
||||
pass # unreadable value: scan now and write a good one back
|
||||
icp.set_param(PARAM_SCAN_RUN, fields.Datetime.to_string(now))
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ queue processing
|
||||
@api.model
|
||||
def cron_process(self, limit=BATCH_SIZE):
|
||||
"""Process the rows that are due; returns how many were handled, failures included."""
|
||||
client = self.env['odoosh.client']
|
||||
if not client._enabled():
|
||||
return 0
|
||||
now = fields.Datetime.now()
|
||||
records = self.sudo().search([
|
||||
('state', '=', 'pending'),
|
||||
'|', ('next_retry_at', '=', False), ('next_retry_at', '<=', now),
|
||||
], order='id asc', limit=limit)
|
||||
if not records:
|
||||
return 0
|
||||
ctx = self.env['res.users']._odoosh_scope_ctx() # shared by the whole batch
|
||||
for rec in records:
|
||||
rec._process_one(ctx)
|
||||
return len(records)
|
||||
|
||||
def _process_one(self, ctx=None):
|
||||
"""Process one row; returns whether it succeeded (skipped counts as success).
|
||||
Each row commits on its own so one failure never rolls back the others."""
|
||||
self.ensure_one()
|
||||
client = self.env['odoosh.client']
|
||||
user = self.user_id.sudo() if self.user_id else self.env['res.users']
|
||||
try:
|
||||
if self.operation == 'delete':
|
||||
payload = {'external_id': self.external_id}
|
||||
result = client.delete_user(self.external_id)
|
||||
self._finish('done', payload, result)
|
||||
if user.exists():
|
||||
user._odoosh_mark('none')
|
||||
return True
|
||||
|
||||
if self.operation == 'deactivate':
|
||||
payload = {'external_id': self.external_id, 'stop_envs': True}
|
||||
result = client.deactivate_user(self.external_id, stop_envs=True)
|
||||
self._finish('done', payload, result)
|
||||
if user.exists():
|
||||
user._odoosh_mark('none')
|
||||
return True
|
||||
|
||||
if not user.exists():
|
||||
self._mark_failed(_("The user was deleted; there is nothing left to sync"), 'user_gone')
|
||||
return False
|
||||
payload = user._odoosh_payload(ctx)
|
||||
digest = user._odoosh_payload_hash(payload)
|
||||
if not self.force and user.odoosh_sync_state == 'synced' and user.odoosh_payload_hash == digest:
|
||||
self._finish('skipped', payload, {'note': 'unchanged'}) # nothing changed, leave the platform alone
|
||||
return True
|
||||
try:
|
||||
result = client.upsert_user(payload)
|
||||
except OdooshPermanent as err:
|
||||
# Platform username already taken (typically Odoo's `admin` colliding with the
|
||||
# platform's own admin): retry once with a "-<odoo user id>" suffix and remember it.
|
||||
if err.code != 'username_taken' or (user.odoosh_username or '').strip():
|
||||
raise
|
||||
alt = ('%s-%s' % (payload['username'], user.id))[:64]
|
||||
payload['username'] = alt
|
||||
if payload.get('provision'):
|
||||
payload['provision']['name'] = alt
|
||||
result = client.upsert_user(payload)
|
||||
user.with_context(odoosh_no_sync=True).write({'odoosh_username': alt})
|
||||
digest = user._odoosh_payload_hash(payload)
|
||||
self._finish('done', payload, result)
|
||||
user._odoosh_mark('synced', payload_hash=digest, result=result)
|
||||
return True
|
||||
except OdooshPermanent as err:
|
||||
self._mark_failed(err.message, err.code)
|
||||
if user.exists():
|
||||
user._odoosh_mark('failed', error='[%s] %s' % (err.code, err.message))
|
||||
return False
|
||||
except OdooshRetryable as err:
|
||||
self._mark_retry(err.message, err.code)
|
||||
if user.exists():
|
||||
user._odoosh_mark('pending', error='[%s] %s' % (err.code, err.message))
|
||||
return False
|
||||
except Exception as err: # noqa: BLE001 - unexpected errors are retried too, never fatal
|
||||
_logger.exception("OdooshCN sync crashed on log#%s", self.id)
|
||||
self._mark_retry(str(err), 'unexpected')
|
||||
return False
|
||||
finally:
|
||||
# Commit per row: progress survives the thread or worker being killed mid-way
|
||||
self.env.cr.commit()
|
||||
|
||||
def _finish(self, state, payload, result):
|
||||
self.write({
|
||||
'state': state,
|
||||
'attempts': self.attempts + 1,
|
||||
'synced_at': fields.Datetime.now(),
|
||||
'payload_preview': self._pretty(payload),
|
||||
'result': self._pretty(result),
|
||||
'last_error': False,
|
||||
'error_code': False,
|
||||
'next_retry_at': False,
|
||||
'force': False,
|
||||
})
|
||||
|
||||
def _mark_retry(self, message, code):
|
||||
attempts = self.attempts + 1
|
||||
if attempts > len(BACKOFF_MINUTES):
|
||||
return self._mark_failed(
|
||||
_("Gave up after %(n)s attempts. Last error: %(err)s", n=attempts - 1, err=message), code)
|
||||
delay = BACKOFF_MINUTES[attempts - 1]
|
||||
self.write({
|
||||
'attempts': attempts,
|
||||
'last_error': message,
|
||||
'error_code': code,
|
||||
'next_retry_at': fields.Datetime.now() + timedelta(minutes=delay),
|
||||
})
|
||||
_logger.warning("OdooshCN sync postponed log#%s (%s), retry in %s min: %s", self.id, code, delay, message)
|
||||
|
||||
def _mark_failed(self, message, code):
|
||||
self.write({
|
||||
'state': 'failed',
|
||||
'attempts': self.attempts + 1,
|
||||
'last_error': message,
|
||||
'error_code': code,
|
||||
'next_retry_at': False,
|
||||
})
|
||||
_logger.error("OdooshCN sync failed log#%s (%s): %s", self.id, code, message)
|
||||
|
||||
@staticmethod
|
||||
def _pretty(data):
|
||||
try:
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
except (TypeError, ValueError):
|
||||
return str(data)
|
||||
|
||||
# ------------------------------------------------------------------ periodic scan
|
||||
@api.model
|
||||
def cron_scan(self, limit=SCAN_BATCH, kick=True):
|
||||
"""Walk users by write_date to catch whatever the hooks missed. Only rows changed
|
||||
since the last scan are read, never the whole table.
|
||||
|
||||
Cost control: one indexed query, one in-memory fingerprint comparison per user,
|
||||
at most SCAN_BATCH users per round; the cursor only advances to the last user
|
||||
actually inspected, so nobody is skipped."""
|
||||
if not self.env['odoosh.client']._enabled():
|
||||
return 0
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
last = icp.get_param(PARAM_LAST_SCAN)
|
||||
Users = self.env['res.users'].sudo()
|
||||
ctx = Users._odoosh_scope_ctx()
|
||||
|
||||
domain = [('odoosh_sync_enabled', '=', True), ('share', '=', False)]
|
||||
if last:
|
||||
domain.append(('write_date', '>', last))
|
||||
# Archived users matter too, so active_test is off; deactivation is only sent for
|
||||
# accounts the platform already knows.
|
||||
users = Users.with_context(active_test=False).search(domain, order='write_date asc, id asc', limit=limit)
|
||||
if not users:
|
||||
icp.set_param(PARAM_LAST_SCAN, fields.Datetime.to_string(fields.Datetime.now()))
|
||||
return 0
|
||||
|
||||
to_upsert = Users.browse()
|
||||
to_deactivate = Users.browse()
|
||||
in_scope = users._odoosh_filter_in_scope(ctx)
|
||||
for u in users:
|
||||
if u not in in_scope:
|
||||
continue # switch off, portal or built-in account
|
||||
if not u.active:
|
||||
if u.odoosh_sync_state in ('synced', 'pending', 'failed'):
|
||||
to_deactivate |= u
|
||||
continue
|
||||
if u.odoosh_sync_state != 'synced' or u.odoosh_payload_hash != u._odoosh_payload_hash(ctx=ctx):
|
||||
to_upsert |= u
|
||||
|
||||
if to_upsert:
|
||||
self.enqueue(to_upsert, 'upsert')
|
||||
to_upsert._odoosh_mark('pending')
|
||||
if to_deactivate:
|
||||
self.enqueue(to_deactivate, 'deactivate')
|
||||
|
||||
# Cursor: when the batch was full, only advance to its last write_date
|
||||
cursor = users[-1].write_date if len(users) >= limit else fields.Datetime.now()
|
||||
icp.set_param(PARAM_LAST_SCAN, fields.Datetime.to_string(cursor))
|
||||
n = len(to_upsert) + len(to_deactivate)
|
||||
if n:
|
||||
_logger.info("OdooshCN scan: inspected %s users, queued %s changes", len(users), n)
|
||||
self.env.cr.commit()
|
||||
if kick:
|
||||
self.kick_async()
|
||||
return n
|
||||
|
||||
# ------------------------------------------------------------------ manual actions
|
||||
def action_retry(self):
|
||||
"""Put failed or cancelled rows back in the queue and process them at once."""
|
||||
self.write({'state': 'pending', 'attempts': 0, 'next_retry_at': False, 'force': True})
|
||||
ctx = self.env['res.users']._odoosh_scope_ctx()
|
||||
for rec in self:
|
||||
rec._process_one(ctx)
|
||||
return True
|
||||
|
||||
def action_cancel(self):
|
||||
self.filtered(lambda r: r.state in ('pending', 'failed')).write({'state': 'cancelled', 'next_retry_at': False})
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ reconciliation
|
||||
@api.model
|
||||
def cron_reconcile(self):
|
||||
"""Daily reconciliation: send the full list of users who should have an account,
|
||||
so the platform can deactivate the ones that are gone.
|
||||
|
||||
An incomplete list would deactivate everyone, so an empty list is skipped here and
|
||||
the platform applies a ratio guard of its own."""
|
||||
client = self.env['odoosh.client']
|
||||
if not client._enabled():
|
||||
return False
|
||||
users = self.env['res.users'].sudo().search([('active', '=', True)])._odoosh_filter_in_scope()
|
||||
if not users:
|
||||
_logger.warning("OdooshCN reconciliation skipped: the local list is empty")
|
||||
return False
|
||||
try:
|
||||
result = client.reconcile([u.id for u in users], stop_envs=True, confirm=False)
|
||||
_logger.info("OdooshCN reconciliation done: %s users sent, %s deactivated on the platform",
|
||||
len(users), result.get('deactivated'))
|
||||
except OdooshPermanent as err:
|
||||
# The ratio guard lands here: never auto-confirm, let an administrator decide
|
||||
_logger.warning("OdooshCN reconciliation refused (%s): %s", err.code, err.message)
|
||||
except OdooshRetryable as err:
|
||||
_logger.warning("OdooshCN reconciliation postponed (%s): %s", err.code, err.message)
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def action_sync_all(self):
|
||||
"""Re-queue every user in scope, ignoring fingerprints."""
|
||||
users = self.env['res.users'].sudo().search([])._odoosh_filter_in_scope()
|
||||
if not users:
|
||||
raise UserError(_("No user is in sync scope. Check the settings and the per-user switch."))
|
||||
self.enqueue(users, 'upsert', force=True)
|
||||
users._odoosh_mark('pending')
|
||||
self.env.cr.commit()
|
||||
self.kick_async()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Queued"),
|
||||
'message': _("%s users queued; the background worker has started.", len(users)),
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Settings: platform connection and the defaults used for auto-provisioned environments.
|
||||
|
||||
The access token lives in ir.config_parameter, readable only by system administrators.
|
||||
Nothing sent to the browser ever contains it in clear text.
|
||||
"""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from .odoosh_client import OdooshError
|
||||
|
||||
MASK = '********'
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
odoosh_enabled = fields.Boolean(
|
||||
string='Enable OdooshCN sync',
|
||||
config_parameter='odoosh.enabled',
|
||||
help="Master switch. When off nothing is pushed and queued rows are left alone.")
|
||||
odoosh_base_url = fields.Char(
|
||||
string='Platform URL',
|
||||
config_parameter='odoosh.base_url',
|
||||
help="Address of OdooshCN, for example https://lab.school.edu. No trailing slash.")
|
||||
odoosh_token = fields.Char(
|
||||
string='Access token',
|
||||
help="Created by an OdooshCN super administrator under Access tokens. Stored on the server only.")
|
||||
odoosh_token_set = fields.Boolean(string='Token configured', compute='_compute_token_set')
|
||||
|
||||
odoosh_tenant = fields.Char(
|
||||
string='Default organisation',
|
||||
config_parameter='odoosh.tenant',
|
||||
help="Slug of the platform organisation users land in. Empty means the organisation the token "
|
||||
"is bound to. Another organisation requires the token to allow it.")
|
||||
odoosh_default_version = fields.Char(
|
||||
string='Default Odoo version', config_parameter='odoosh.default_version', default='18',
|
||||
help="Odoo version used for new lab environments.")
|
||||
odoosh_default_lang = fields.Char(
|
||||
string='Default language', config_parameter='odoosh.default_lang', default='en_US',
|
||||
help="Initial language of new lab environments, for example en_US or zh_CN.")
|
||||
odoosh_default_mem_mb = fields.Integer(
|
||||
string='Memory limit per environment (MB)', config_parameter='odoosh.default_mem_mb',
|
||||
help="Empty uses the platform default. This value times the number of concurrent users "
|
||||
"should stay below the server memory.")
|
||||
odoosh_default_sleep_hours = fields.Float(
|
||||
string='Idle sleep (hours)', config_parameter='odoosh.default_sleep_hours',
|
||||
help="How long an environment may stay idle before it is put to sleep to free memory. "
|
||||
"It wakes up on the next visit. 0 disables sleeping.")
|
||||
|
||||
# ------------------------------------------------------------------ token
|
||||
@api.depends('odoosh_base_url')
|
||||
def _compute_token_set(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
has = bool((icp.get_param('odoosh.token') or '').strip())
|
||||
for rec in self:
|
||||
rec.odoosh_token_set = has
|
||||
|
||||
@api.model
|
||||
def get_values(self):
|
||||
values = super().get_values()
|
||||
token = (self.env['ir.config_parameter'].sudo().get_param('odoosh.token') or '').strip()
|
||||
# Only a mask plus the last 4 characters: enough to confirm it is set, never the secret
|
||||
values['odoosh_token'] = (MASK + token[-4:]) if token else ''
|
||||
return values
|
||||
|
||||
# Changing any of these changes the sync scope or target, so existing users are rescanned
|
||||
SCOPE_PARAMS = ('odoosh.enabled', 'odoosh.tenant', 'odoosh.base_url', 'odoosh.token')
|
||||
|
||||
def set_values(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
before = {k: icp.get_param(k) for k in self.SCOPE_PARAMS}
|
||||
super().set_values()
|
||||
value = (self.odoosh_token or '').strip()
|
||||
if not value:
|
||||
icp.set_param('odoosh.token', '')
|
||||
elif not value.startswith(MASK): # the mask sent back untouched means "unchanged"
|
||||
icp.set_param('odoosh.token', value)
|
||||
after = {k: icp.get_param(k) for k in self.SCOPE_PARAMS}
|
||||
if after != before and after.get('odoosh.enabled') in ('True', 'true', '1', True):
|
||||
# Reset the scan cursor and rescan every user in the background after commit
|
||||
icp.set_param('odoosh.last_scan_at', '')
|
||||
dbname = self.env.cr.dbname
|
||||
self.env.cr.postcommit.add(
|
||||
lambda: self.env['odoosh.sync.log'].kick_async(dbname, scan_first=True))
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def action_odoosh_test(self):
|
||||
"""Connectivity check: probes the platform, does not depend on any synced user, and
|
||||
reports whether the token carries the scopes this module needs."""
|
||||
self.ensure_one()
|
||||
client = self.env['odoosh.client']
|
||||
try:
|
||||
info = client.ping()
|
||||
except OdooshError as err:
|
||||
hint = ''
|
||||
if err.code == 'http_404':
|
||||
hint = _(" The platform answered 404, which usually means it still runs an older "
|
||||
"version without the integration API. Rebuild and restart it first.")
|
||||
elif err.code == 'unreachable':
|
||||
hint = _(" Check the URL and whether this server can reach it.")
|
||||
raise UserError(_("Connection failed (%(code)s): %(msg)s%(hint)s",
|
||||
code=err.code, msg=err.message, hint=hint))
|
||||
if not info.get('ok'):
|
||||
raise UserError(_("The platform returned something unexpected. Check its version."))
|
||||
|
||||
need = {'handoff': _("passwordless entry"), 'users:write': _("user sync"),
|
||||
'envs:read': _("read instances"), 'envs:write': _("create instances")}
|
||||
have = set(info.get('scopes') or [])
|
||||
missing = [label for scope, label in need.items() if scope not in have]
|
||||
limits = info.get('limits') or {}
|
||||
lines = [
|
||||
_("Organisation: %(name)s (%(slug)s)", name=info.get('tenant_name'), slug=info.get('tenant_slug')),
|
||||
_("Source: %s", info.get('source')),
|
||||
_("Scopes: %s", ', '.join(need.get(s, s) for s in sorted(have)) or _("none")),
|
||||
_("Limits: up to %(role)s, %(n)s instance(s) per user",
|
||||
role=limits.get('max_grant_role'), n=limits.get('max_envs_per_user')),
|
||||
]
|
||||
if not limits.get('allow_delete_user'):
|
||||
lines.append(_("Note: this token may not delete users, so deleting a user here only "
|
||||
"deactivates the platform account."))
|
||||
if info.get('expires_at'):
|
||||
lines.append(_("Token expires: %s", str(info['expires_at'])[:10]))
|
||||
if missing:
|
||||
lines.append(_("Missing scopes: %s. Edit the access token on the platform.", ', '.join(missing)))
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Connection OK") if not missing else _("Connected, but the token lacks scopes"),
|
||||
'message': '\n'.join(lines),
|
||||
'type': 'success' if not missing else 'warning',
|
||||
'sticky': bool(missing),
|
||||
},
|
||||
}
|
||||
|
||||
def action_odoosh_sync_all(self):
|
||||
"""Re-queue every user in scope."""
|
||||
return self.env['odoosh.sync.log'].sudo().action_sync_all()
|
||||
|
||||
def action_odoosh_open_logs(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Sync queue"),
|
||||
'res_model': 'odoosh.sync.log',
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""User synchronisation: the hooks, and the platform permission fields on the user.
|
||||
|
||||
There are only three rules, all implemented here:
|
||||
1. Once the platform URL and token are configured, creating a user syncs it right away
|
||||
and turns that user's sync switch on.
|
||||
2. Updating a user syncs when the switch is on and does nothing when it is off.
|
||||
3. Archiving deactivates the platform account, deleting deletes it, and restoring an
|
||||
archived user activates it again.
|
||||
|
||||
Everything the platform knows about a user - role, organisation, quotas, switches, menu
|
||||
visibility - is a field on the user form, so there is no hidden policy in the code.
|
||||
|
||||
Syncing never issues HTTP inside a request: the hooks only write a local queue row and a
|
||||
dedicated thread pushes after commit (see odoosh_sync_log.py).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Fields that end up in the payload; only these are worth a push
|
||||
POLICY_FIELDS = (
|
||||
'odoosh_username', 'odoosh_role', 'odoosh_tenant', 'odoosh_tenant_role',
|
||||
'odoosh_shell_enabled', 'odoosh_can_use_enterprise', 'odoosh_auto_provision',
|
||||
'odoosh_max_envs', 'odoosh_max_dbs',
|
||||
'odoosh_menu_dashboard', 'odoosh_menu_repos', 'odoosh_menu_ai', 'odoosh_menu_backups',
|
||||
)
|
||||
WATCHED_FIELDS = {'login', 'name', 'active', 'odoosh_sync_enabled', *POLICY_FIELDS}
|
||||
# Platform username rule: starts alphanumeric, may contain . _ - , 2 to 64 characters
|
||||
USERNAME_SAFE = re.compile(r'[^A-Za-z0-9_.-]')
|
||||
# Built-in accounts that are never synced: OdooBot, the public user, the portal template
|
||||
# and the new-user default template. They are not people. Administrator is a real login
|
||||
# and is synced like anybody else.
|
||||
SYSTEM_XMLIDS = ('base.user_root', 'base.public_user', 'base.template_user', 'base.default_user')
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
# ------------------------------------------------------------------ sync control and status
|
||||
odoosh_sync_enabled = fields.Boolean(
|
||||
string='Sync to lab platform', default=True, index=True,
|
||||
help="When off, nothing about this user is pushed: no updates, and archiving or "
|
||||
"deleting leaves the platform account untouched.")
|
||||
odoosh_sync_state = fields.Selection([
|
||||
('none', 'Not synced'),
|
||||
('pending', 'Pending'),
|
||||
('synced', 'Synced'),
|
||||
('failed', 'Failed'),
|
||||
], string='Sync status', default='none', readonly=True, copy=False, index=True)
|
||||
odoosh_synced_at = fields.Datetime(string='Last synced', readonly=True, copy=False)
|
||||
odoosh_last_error = fields.Text(string='Sync error', readonly=True, copy=False)
|
||||
odoosh_payload_hash = fields.Char(string='Payload fingerprint', readonly=True, copy=False,
|
||||
help="Fingerprint of the last successful push; identical content is not resent.")
|
||||
odoosh_platform_user_id = fields.Integer(string='Platform user ID', readonly=True, copy=False)
|
||||
odoosh_platform_username = fields.Char(string='Platform username', readonly=True, copy=False,
|
||||
help="The username actually in use on the platform.")
|
||||
|
||||
# ------------------------------------------------------------------ platform permissions
|
||||
odoosh_username = fields.Char(
|
||||
string='Platform username override', copy=False,
|
||||
help="Leave empty to derive it from the login (the part before @, cleaned up). Must be unique on the platform.")
|
||||
odoosh_role = fields.Selection([
|
||||
('member', 'Regular user'),
|
||||
('admin', 'Platform administrator'),
|
||||
], string='Platform role', default='member', required=True,
|
||||
help="A platform administrator sees every organisation. Requires the access token to allow it.")
|
||||
odoosh_tenant = fields.Char(
|
||||
string='Organisation', copy=False,
|
||||
help="Slug of the platform organisation. Empty falls back to the default in the settings, "
|
||||
"then to the organisation the token is bound to. Another organisation requires the token to allow it.")
|
||||
odoosh_tenant_role = fields.Selection([
|
||||
('tester', 'Tester (open instances only)'),
|
||||
('developer', 'Developer (edit code, read logs, reset)'),
|
||||
('owner', 'Organisation manager (see every instance)'),
|
||||
], string='Role in organisation', default='developer', required=True,
|
||||
help="Must not exceed the access token's maximum grantable role.")
|
||||
odoosh_shell_enabled = fields.Boolean(string='Web Shell', default=True,
|
||||
help="Allow opening a terminal on the instance, needed for psql.")
|
||||
odoosh_can_use_enterprise = fields.Boolean(string='Enterprise edition', default=False,
|
||||
help="Allow creating enterprise edition instances.")
|
||||
odoosh_auto_provision = fields.Boolean(string='Auto-provision environment', default=True,
|
||||
help="On sync, create a lab environment if this user has none yet.")
|
||||
odoosh_max_envs = fields.Integer(string='Instance limit', default=1, help="0 means unlimited.")
|
||||
odoosh_max_dbs = fields.Integer(string='Extra database limit', default=2, help="0 means unlimited.")
|
||||
odoosh_menu_dashboard = fields.Boolean(string='Menu: Overview', default=False)
|
||||
odoosh_menu_repos = fields.Boolean(string='Menu: Git repositories', default=False)
|
||||
odoosh_menu_ai = fields.Boolean(string='Menu: AI assistant', default=True)
|
||||
odoosh_menu_backups = fields.Boolean(string='Menu: Backups', default=False)
|
||||
|
||||
# Readable by the user themselves on the preferences page, but not writable
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + ['odoosh_sync_state', 'odoosh_synced_at', 'odoosh_platform_username']
|
||||
|
||||
# ------------------------------------------------------------------ scope
|
||||
@api.model
|
||||
def _odoosh_configured(self):
|
||||
"""Hooks stay silent until the URL, the token and the master switch are all set."""
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
return (icp.get_param('odoosh.enabled') in ('True', 'true', '1', True)
|
||||
and bool((icp.get_param('odoosh.base_url') or '').strip())
|
||||
and bool((icp.get_param('odoosh.token') or '').strip()))
|
||||
|
||||
@api.model
|
||||
def _odoosh_scope_ctx(self):
|
||||
"""Constants shared by a whole batch: the ids of the built-in accounts."""
|
||||
system_ids = set()
|
||||
for xmlid in SYSTEM_XMLIDS:
|
||||
rec = self.env.ref(xmlid, raise_if_not_found=False)
|
||||
if rec:
|
||||
system_ids.add(rec.id)
|
||||
return {'system_ids': system_ids}
|
||||
|
||||
def _odoosh_eligible(self, ctx=None):
|
||||
"""Accounts that may be synced: internal users that are not built-in (switch ignored)."""
|
||||
ctx = ctx or self._odoosh_scope_ctx()
|
||||
system_ids = ctx['system_ids']
|
||||
return self.sudo().filtered(lambda u: not u.share and u.id not in system_ids)
|
||||
|
||||
def _odoosh_filter_in_scope(self, ctx=None):
|
||||
"""Users to sync: eligible and switch on."""
|
||||
return self._odoosh_eligible(ctx).filtered('odoosh_sync_enabled')
|
||||
|
||||
def _odoosh_in_scope(self, ctx=None):
|
||||
self.ensure_one()
|
||||
return bool(self._odoosh_filter_in_scope(ctx))
|
||||
|
||||
# ------------------------------------------------------------------ payload
|
||||
def _odoosh_username_auto(self):
|
||||
"""Derive the platform username from the login: the part before @, cleaned up."""
|
||||
self.ensure_one()
|
||||
raw = (self.login or '').split('@')[0]
|
||||
cleaned = USERNAME_SAFE.sub('', raw) or ('u%s' % self.id)
|
||||
if not cleaned[0].isalnum():
|
||||
cleaned = 'u' + cleaned
|
||||
if len(cleaned) < 2:
|
||||
cleaned = 'u%s' % self.id
|
||||
return cleaned[:64]
|
||||
|
||||
def _odoosh_tenant_value(self):
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
return (self.odoosh_tenant or '').strip() or (icp.get_param('odoosh.tenant') or '').strip() or None
|
||||
|
||||
def _odoosh_payload(self, ctx=None):
|
||||
"""What is sent to the platform; every value comes from a field on this user."""
|
||||
self.ensure_one()
|
||||
payload = {
|
||||
'external_id': str(self.id),
|
||||
'username': (self.odoosh_username or '').strip() or self._odoosh_username_auto(),
|
||||
'display_name': self.name or self.login,
|
||||
'active': bool(self.active),
|
||||
'role': self.odoosh_role or 'member',
|
||||
'tenant': self._odoosh_tenant_value(),
|
||||
'tenant_role': self.odoosh_tenant_role or 'developer',
|
||||
'shell_enabled': bool(self.odoosh_shell_enabled),
|
||||
'can_use_enterprise': bool(self.odoosh_can_use_enterprise),
|
||||
'quota': {
|
||||
'max_envs': None if not self.odoosh_max_envs else int(self.odoosh_max_envs), # 0 = unlimited
|
||||
'max_dbs': None if not self.odoosh_max_dbs else int(self.odoosh_max_dbs),
|
||||
},
|
||||
'menus': {
|
||||
'dashboard': bool(self.odoosh_menu_dashboard),
|
||||
'repos': bool(self.odoosh_menu_repos),
|
||||
'ai': bool(self.odoosh_menu_ai),
|
||||
'backups': bool(self.odoosh_menu_backups),
|
||||
},
|
||||
}
|
||||
if self.odoosh_auto_provision and self.active:
|
||||
payload['provision'] = self._odoosh_env_spec()
|
||||
return payload
|
||||
|
||||
def _odoosh_env_spec(self):
|
||||
"""Specification of the auto-provisioned environment: named after the platform
|
||||
username, everything else from the settings."""
|
||||
self.ensure_one()
|
||||
icp = self.env['ir.config_parameter'].sudo()
|
||||
spec = {
|
||||
'name': (self.odoosh_username or '').strip() or self._odoosh_username_auto(),
|
||||
'tenant': self._odoosh_tenant_value(),
|
||||
'kind': 'dev',
|
||||
'odoo_version': icp.get_param('odoosh.default_version') or '18',
|
||||
'edition': 'community',
|
||||
'lang': icp.get_param('odoosh.default_lang') or 'en_US',
|
||||
}
|
||||
for key, param, cast in (('mem_mb', 'odoosh.default_mem_mb', int),
|
||||
('sleep_idle_hours', 'odoosh.default_sleep_hours', float)):
|
||||
raw = icp.get_param(param)
|
||||
if raw:
|
||||
try:
|
||||
spec[key] = cast(raw)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return spec
|
||||
|
||||
def _odoosh_payload_hash(self, payload=None, ctx=None):
|
||||
self.ensure_one()
|
||||
data = payload if payload is not None else self._odoosh_payload(ctx)
|
||||
raw = json.dumps(data, sort_keys=True, ensure_ascii=False, separators=(',', ':'))
|
||||
return hashlib.sha1(raw.encode('utf-8')).hexdigest()
|
||||
|
||||
# ------------------------------------------------------------------ hooks
|
||||
def _odoosh_kick(self):
|
||||
dbname = self.env.cr.dbname
|
||||
self.env.cr.postcommit.add(lambda: self.env['odoosh.sync.log'].kick_async(dbname))
|
||||
|
||||
def _odoosh_enqueue(self, operation):
|
||||
"""Queue and let the post-commit thread push. Local writes only; every exception is
|
||||
swallowed and logged, because syncing must never block user management."""
|
||||
if self.env.context.get('odoosh_no_sync'):
|
||||
return
|
||||
try:
|
||||
if not self._odoosh_configured():
|
||||
return
|
||||
targets = self._odoosh_filter_in_scope()
|
||||
if not targets:
|
||||
return
|
||||
self.env['odoosh.sync.log'].sudo().enqueue(targets, operation)
|
||||
targets.with_context(odoosh_no_sync=True).write({'odoosh_sync_state': 'pending'})
|
||||
self._odoosh_kick()
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN enqueue failed (ignored, user operation unaffected)")
|
||||
|
||||
def _odoosh_enqueue_known(self, operation):
|
||||
"""Deactivate or delete: only for users whose switch is on and that the platform knows."""
|
||||
if self.env.context.get('odoosh_no_sync'):
|
||||
return
|
||||
try:
|
||||
if not self._odoosh_configured():
|
||||
return
|
||||
known = self.sudo().filtered(
|
||||
lambda u: u.odoosh_sync_enabled and u.odoosh_sync_state in ('synced', 'pending', 'failed'))
|
||||
if not known:
|
||||
return
|
||||
queue = self.env['odoosh.sync.log'].sudo()
|
||||
if operation == 'delete':
|
||||
queue.enqueue_delete(known)
|
||||
else:
|
||||
queue.enqueue(known, operation)
|
||||
self._odoosh_kick()
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("OdooshCN %s enqueue failed (ignored)", operation)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
users = super().create(vals_list)
|
||||
if self._odoosh_configured():
|
||||
# Configured: sync immediately and switch the new users on
|
||||
off = users._odoosh_eligible().filtered(lambda u: not u.odoosh_sync_enabled)
|
||||
if off:
|
||||
off.with_context(odoosh_no_sync=True).write({'odoosh_sync_enabled': True})
|
||||
users._odoosh_enqueue('upsert')
|
||||
return users
|
||||
|
||||
def write(self, vals):
|
||||
result = super().write(vals)
|
||||
if self.env.context.get('odoosh_no_sync') or not (WATCHED_FIELDS & set(vals)):
|
||||
return result
|
||||
if 'odoosh_sync_enabled' in vals and not vals['odoosh_sync_enabled']:
|
||||
return result # just switched off: leave the platform as it is
|
||||
todo = self.sudo().filtered('odoosh_sync_enabled')
|
||||
if not todo:
|
||||
return result # switch off: nothing to do
|
||||
if 'active' in vals and not vals['active']:
|
||||
todo._odoosh_enqueue_known('deactivate') # archived -> deactivate on the platform
|
||||
else:
|
||||
todo._odoosh_enqueue('upsert') # edited, or restored from the archive
|
||||
return result
|
||||
|
||||
def unlink(self):
|
||||
# Deleted here means deleted there. The platform moves the user's environments to
|
||||
# its recycle bin first, so the data stays recoverable for a while.
|
||||
self.sudo().filtered('odoosh_sync_enabled')._odoosh_enqueue_known('delete')
|
||||
return super().unlink()
|
||||
|
||||
# ------------------------------------------------------------------ status write-back
|
||||
def _odoosh_mark(self, state, error=None, payload_hash=None, result=None):
|
||||
"""Called by the sync thread. The odoosh_no_sync context stops it from looping."""
|
||||
vals = {'odoosh_sync_state': state, 'odoosh_last_error': error or False}
|
||||
if state == 'synced':
|
||||
vals['odoosh_synced_at'] = fields.Datetime.now()
|
||||
if payload_hash:
|
||||
vals['odoosh_payload_hash'] = payload_hash
|
||||
if result:
|
||||
if result.get('user_id'):
|
||||
vals['odoosh_platform_user_id'] = int(result['user_id'])
|
||||
if result.get('username'):
|
||||
vals['odoosh_platform_username'] = result['username']
|
||||
self.sudo().with_context(odoosh_no_sync=True).write(vals)
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def action_odoosh_sync_now(self):
|
||||
"""Sync the selected users straight away, without waiting for the queue."""
|
||||
queue = self.env['odoosh.sync.log'].sudo()
|
||||
if not self._odoosh_configured():
|
||||
return self._odoosh_notify(
|
||||
_("Not configured"),
|
||||
_("Set the platform URL and access token in Settings > OdooshCN Lab Environments first."),
|
||||
'warning')
|
||||
targets = self._odoosh_filter_in_scope()
|
||||
if not targets:
|
||||
ctx = self._odoosh_scope_ctx()
|
||||
reasons = []
|
||||
for u in self.sudo():
|
||||
if u.id in ctx['system_ids']:
|
||||
reasons.append(_("%s: built-in Odoo account, never synced", u.login))
|
||||
elif u.share:
|
||||
reasons.append(_("%s: portal user, never synced", u.login))
|
||||
elif not u.odoosh_sync_enabled:
|
||||
reasons.append(_("%s: sync switch is off", u.login))
|
||||
return self._odoosh_notify(_("Nothing to sync"),
|
||||
'\n'.join(reasons) or _("None of the selected users is in scope."),
|
||||
'warning')
|
||||
logs = queue.enqueue(targets, 'upsert', force=True)
|
||||
ok = sum(1 for log in logs if log._process_one())
|
||||
return self._odoosh_notify(
|
||||
_("Sync finished"),
|
||||
_("%(ok)s of %(total)s succeeded. Open the sync queue to see why the others failed.",
|
||||
ok=ok, total=len(logs)),
|
||||
'success' if ok == len(logs) else 'warning')
|
||||
|
||||
def action_odoosh_enable_sync(self):
|
||||
self.write({'odoosh_sync_enabled': True})
|
||||
return self._odoosh_notify(_("Sync enabled"),
|
||||
_("%s users will be pushed within a few seconds.", len(self)), 'success')
|
||||
|
||||
def action_odoosh_disable_sync(self):
|
||||
self.with_context(odoosh_no_sync=True).write({'odoosh_sync_enabled': False})
|
||||
return self._odoosh_notify(_("Sync disabled"),
|
||||
_("%s users will no longer be pushed; their platform accounts stay as they are.",
|
||||
len(self)), 'info')
|
||||
|
||||
def action_odoosh_envs(self):
|
||||
self.ensure_one()
|
||||
return self.env['odoosh.client'].list_envs(str(self.id)).get('envs', [])
|
||||
|
||||
@staticmethod
|
||||
def _odoosh_notify(title, message, kind='info'):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {'title': title, 'message': message, 'type': kind, 'sticky': False},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_odoosh_sync_log_manager,odoosh.sync.log.manager,model_odoosh_sync_log,group_odoosh_teacher,1,1,0,0
|
||||
access_odoosh_sync_log_system,odoosh.sync.log.system,model_odoosh_sync_log,base.group_system,1,1,1,1
|
||||
|
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="module_category_odoosh" model="ir.module.category">
|
||||
<field name="name">Lab Environments</field>
|
||||
<field name="description">OdooshCN lab environment integration</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<!-- Manager: can see the sync queue and every user's platform settings -->
|
||||
<record id="group_odoosh_teacher" model="res.groups">
|
||||
<field name="name">Lab Environment Manager</field>
|
||||
<field name="category_id" ref="module_category_odoosh"/>
|
||||
<field name="comment">Can inspect the sync queue and manage the platform settings of other users.</field>
|
||||
</record>
|
||||
|
||||
<!-- Only managers and system administrators may read the sync queue -->
|
||||
<record id="rule_sync_log_teacher" model="ir.rule">
|
||||
<field name="name">Sync queue: managers only</field>
|
||||
<field name="model_id" ref="model_odoosh_sync_log"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,158 @@
|
||||
/** @odoo-module **/
|
||||
/**
|
||||
* The "My Lab Environment" client action.
|
||||
*
|
||||
* Shows the current user's environments as cards, and opens the platform through a
|
||||
* passwordless URL fetched from the server.
|
||||
*
|
||||
* The console (instance list, instance detail) is embedded in an iframe on this page, so
|
||||
* students stay inside the course system; the frame keeps an "Open in a new tab" button
|
||||
* because it does stack two top bars.
|
||||
*
|
||||
* The instance's own Odoo opens in a real tab instead: it is a full Odoo interface, and
|
||||
* nesting it here would stack three navigation bars and two sidebars, leaving nobody sure
|
||||
* which layer they are in.
|
||||
*
|
||||
* The platform has to allow being framed. Its console answers with X-Frame-Options
|
||||
* SAMEORIGIN by default, and the frame then stays blank; the administrator names this Odoo
|
||||
* in the console frame-ancestors policy (see frontend/nginx-console.inc on the platform).
|
||||
*
|
||||
* The URL is short lived and single use, so it is fetched on click and never cached - the
|
||||
* "new tab" button fetches one of its own instead of reusing the framed one.
|
||||
*/
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
const STATUS_LABEL = {
|
||||
running: _t("Running"),
|
||||
sleeping: _t("Sleeping"),
|
||||
stopped: _t("Stopped"),
|
||||
waking: _t("Waking up"),
|
||||
starting: _t("Starting"),
|
||||
provisioning: _t("Creating"),
|
||||
restarting: _t("Restarting"),
|
||||
deploying: _t("Deploying"),
|
||||
failed: _t("Failed"),
|
||||
created: _t("Not created yet"),
|
||||
};
|
||||
|
||||
const STATUS_CLASS = {
|
||||
running: "text-bg-success",
|
||||
sleeping: "text-bg-warning",
|
||||
waking: "text-bg-warning",
|
||||
starting: "text-bg-info",
|
||||
provisioning: "text-bg-info",
|
||||
restarting: "text-bg-info",
|
||||
deploying: "text-bg-info",
|
||||
failed: "text-bg-danger",
|
||||
};
|
||||
|
||||
export class OdooshMyEnv extends Component {
|
||||
static template = "odoosh_connector.MyEnv";
|
||||
static props = { ...standardActionServiceProps };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
error: null,
|
||||
envs: [],
|
||||
username: null,
|
||||
opening: null,
|
||||
frame: null, // {url, target, env, title} while a page is embedded below
|
||||
});
|
||||
onWillStart(() => this.load());
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/odoosh/my/envs", {});
|
||||
this.state.error = data.error || null;
|
||||
this.state.envs = data.envs || [];
|
||||
this.state.username = data.username || null;
|
||||
} catch {
|
||||
this.state.error = _t("Cannot reach the lab platform. Try again later or ask your administrator.");
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
statusLabel(env) {
|
||||
return STATUS_LABEL[env.status] || env.status;
|
||||
}
|
||||
|
||||
statusClass(env) {
|
||||
return STATUS_CLASS[env.status] || "text-bg-secondary";
|
||||
}
|
||||
|
||||
isBusy(env) {
|
||||
return ["provisioning", "starting", "restarting", "deploying", "waking"].includes(env.status);
|
||||
}
|
||||
|
||||
/** Fetch a passwordless URL and show it. target: console or odoo.
|
||||
* newTab skips the embedded frame and opens a real tab instead. */
|
||||
async open(target, env, newTab = false) {
|
||||
if (this.state.opening) {
|
||||
return;
|
||||
}
|
||||
this.state.opening = `${target}:${env ? env.name : "-"}`;
|
||||
// Open the blank tab first: fetching the URL is async, and opening a tab inside the
|
||||
// callback would be treated as a pop-up and blocked.
|
||||
const tab = newTab ? window.open("", "_blank") : null;
|
||||
try {
|
||||
const data = await rpc("/odoosh/my/url", { target, env: env ? env.name : null });
|
||||
if (data.error) {
|
||||
if (tab) {
|
||||
tab.close();
|
||||
}
|
||||
this.notification.add(data.error, { type: "danger", title: _t("Cannot open") });
|
||||
return;
|
||||
}
|
||||
if (!newTab) {
|
||||
this.state.frame = {
|
||||
url: data.url,
|
||||
target,
|
||||
env: env || null,
|
||||
title: env ? env.name : _t("Developer console"),
|
||||
};
|
||||
} else if (tab) {
|
||||
tab.location.href = data.url;
|
||||
} else {
|
||||
window.location.href = data.url; // pop-up blocked: navigate this tab instead
|
||||
}
|
||||
} catch {
|
||||
if (tab) {
|
||||
tab.close();
|
||||
}
|
||||
this.notification.add(_t("Could not obtain the entry URL. Try again."), { type: "danger" });
|
||||
} finally {
|
||||
this.state.opening = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave the embedded page and go back to the cards. */
|
||||
closeFrame() {
|
||||
this.state.frame = null;
|
||||
}
|
||||
|
||||
/** Same destination as the embedded one, in a real tab. Fetches a fresh URL: the framed
|
||||
* one has already been redeemed. */
|
||||
openFramedInTab() {
|
||||
const f = this.state.frame;
|
||||
if (f) {
|
||||
this.open(f.target, f.env, true);
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("odoosh_my_env", OdooshMyEnv);
|
||||
@@ -0,0 +1,37 @@
|
||||
.o_odoosh_env {
|
||||
max-width: 1200px;
|
||||
|
||||
// 嵌入模式:铺满可用宽高,不然控制台的侧栏会被 1200px 挤成一条
|
||||
&.o_odoosh_env_framed {
|
||||
max-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.o_odoosh_frame {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 70vh; // 外层高度撑不开时的下限,免得 iframe 被压成 0
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: .5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.o_odoosh_card {
|
||||
transition: box-shadow .15s ease, transform .15s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: .85em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="odoosh_connector.MyEnv">
|
||||
<div class="o_odoosh_env o_action p-4" t-att-class="{'o_odoosh_env_framed': state.frame}">
|
||||
|
||||
<!-- Embedded platform page: instance list, instance detail, or the instance's Odoo -->
|
||||
<t t-if="state.frame">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<button class="btn btn-secondary" t-on-click="() => this.closeFrame()">
|
||||
<i class="fa fa-angle-left me-1"/>Back
|
||||
</button>
|
||||
<h4 class="mb-0 flex-grow-1 text-truncate" t-esc="state.frame.title"/>
|
||||
<button class="btn btn-light" t-att-disabled="state.opening"
|
||||
t-on-click="() => this.openFramedInTab()">
|
||||
<i class="fa fa-external-link me-1"/>Open in a new tab
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<iframe class="o_odoosh_frame" t-att-src="state.frame.url" t-att-title="state.frame.title"/>
|
||||
|
||||
<div class="text-muted small mt-2">
|
||||
Stays blank? The platform only lets the sites named in its console frame-ancestors
|
||||
policy embed it, and the address in your browser's bar has to match it exactly,
|
||||
scheme and port included. Ask your administrator, or use the button above.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">My Lab Environment</h2>
|
||||
<div class="text-muted small" t-if="state.username">
|
||||
Platform account: <code t-esc="state.username"/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary" t-on-click="() => this.refresh()" t-att-disabled="state.loading">
|
||||
<i class="fa fa-refresh me-1"/>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="state.loading" class="text-center text-muted py-5">
|
||||
<i class="fa fa-circle-o-notch fa-spin fa-2x"/>
|
||||
<div class="mt-3">Loading your environments...</div>
|
||||
</div>
|
||||
|
||||
<div t-elif="state.error" class="alert alert-warning">
|
||||
<t t-esc="state.error"/>
|
||||
</div>
|
||||
|
||||
<div t-elif="!state.envs.length" class="text-center text-muted py-5">
|
||||
<i class="fa fa-cube fa-3x mb-3 d-block opacity-50"/>
|
||||
<p class="mb-1">No lab environment yet.</p>
|
||||
<p class="small">
|
||||
An environment is created automatically once your account is synced, usually within
|
||||
a few minutes. Ask your administrator if it does not appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div t-else="" class="row g-3">
|
||||
<div t-foreach="state.envs" t-as="env" t-key="env.id" class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100 o_odoosh_card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<h5 class="card-title mb-0 text-truncate" t-esc="env.name"/>
|
||||
<span class="badge ms-2 flex-shrink-0"
|
||||
t-att-class="statusClass(env)" t-esc="statusLabel(env)"/>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mb-3">
|
||||
Odoo <t t-esc="env.odoo_version"/>
|
||||
<t t-if="env.edition === 'enterprise'"> · Enterprise</t>
|
||||
<t t-if="env.kind !== 'dev'"> · <t t-esc="env.kind"/></t>
|
||||
</div>
|
||||
|
||||
<div t-if="isBusy(env) and env.status_message" class="small text-muted mb-3">
|
||||
<i class="fa fa-clock-o me-1"/><t t-esc="env.status_message"/>
|
||||
<t t-if="env.progress"> (<t t-esc="env.progress"/>%)</t>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
t-on-click="() => this.open('odoo', env, true)"
|
||||
t-att-disabled="state.opening or env.status === 'failed'">
|
||||
<i class="fa fa-external-link me-1"/>Open Odoo
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
t-on-click="() => this.open('console', env)"
|
||||
t-att-disabled="state.opening">
|
||||
<i class="fa fa-code me-1"/>Developer console
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div t-if="env.status === 'sleeping'" class="text-muted small mt-2">
|
||||
This environment is asleep to save resources. Opening it wakes it up,
|
||||
which takes a few seconds.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="!state.loading and !state.error" class="mt-4">
|
||||
<button class="btn btn-link px-0" t-on-click="() => this.open('console', null)">
|
||||
See everything in the developer console
|
||||
<i class="fa fa-angle-right ms-1"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_odoosh_my_env" model="ir.actions.client">
|
||||
<field name="name">My Lab Environment</field>
|
||||
<field name="tag">odoosh_my_env</field>
|
||||
</record>
|
||||
|
||||
<!-- Standalone error page for /odoosh/go, so the module only depends on base and web -->
|
||||
<template id="entry_error" name="Lab environment entry error">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Cannot open the lab environment</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,'Segoe UI',sans-serif">
|
||||
<div style="max-width:560px;margin:12vh auto;padding:0 20px">
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:32px 36px;box-shadow:0 1px 3px rgba(0,0,0,.05)">
|
||||
<h2 style="margin:0 0 12px;font-size:20px;color:#dc2626">Cannot open the lab environment</h2>
|
||||
<p style="color:#475569;line-height:1.8;margin:0 0 8px"><t t-esc="message"/></p>
|
||||
<p t-if="code" style="color:#94a3b8;font-size:12px;margin:0 0 20px">
|
||||
Error code: <code t-esc="code"/>
|
||||
</p>
|
||||
<a href="/odoo" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:8px 18px;border-radius:6px">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_odoosh_root"
|
||||
name="Lab Environment"
|
||||
web_icon="odoosh_connector,static/description/icon.png"
|
||||
sequence="55"
|
||||
groups="base.group_user"/>
|
||||
|
||||
<menuitem id="menu_odoosh_my_env"
|
||||
name="My Environment"
|
||||
parent="menu_odoosh_root"
|
||||
action="action_odoosh_my_env"
|
||||
sequence="10"
|
||||
groups="base.group_user"/>
|
||||
|
||||
<menuitem id="menu_odoosh_admin"
|
||||
name="Administration"
|
||||
parent="menu_odoosh_root"
|
||||
sequence="90"
|
||||
groups="odoosh_connector.group_odoosh_teacher,base.group_system"/>
|
||||
|
||||
<menuitem id="menu_odoosh_sync_log"
|
||||
name="Sync Queue"
|
||||
parent="menu_odoosh_admin"
|
||||
action="action_odoosh_sync_log"
|
||||
sequence="10"
|
||||
groups="odoosh_connector.group_odoosh_teacher,base.group_system"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="odoosh_sync_log_view_list" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.list</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sync queue" decoration-danger="state == 'failed'"
|
||||
decoration-muted="state in ('cancelled', 'skipped')" decoration-success="state == 'done'">
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="operation"/>
|
||||
<field name="state"/>
|
||||
<field name="attempts"/>
|
||||
<field name="force" optional="hide"/>
|
||||
<field name="next_retry_at" optional="show"/>
|
||||
<field name="error_code" optional="show"/>
|
||||
<field name="last_error" optional="hide"/>
|
||||
<field name="synced_at" optional="hide"/>
|
||||
<button name="action_retry" type="object" string="Retry" icon="fa-refresh"
|
||||
invisible="state not in ('failed', 'cancelled')"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="odoosh_sync_log_view_form" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.form</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Sync record">
|
||||
<header>
|
||||
<button name="action_retry" type="object" string="Retry now" class="btn-primary"
|
||||
invisible="state == 'done'"/>
|
||||
<button name="action_cancel" type="object" string="Cancel"
|
||||
invisible="state not in ('pending', 'failed')"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="pending,done"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="user_id"/>
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="operation"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="attempts"/>
|
||||
<field name="next_retry_at"/>
|
||||
<field name="synced_at"/>
|
||||
<field name="error_code"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Last error" invisible="not last_error">
|
||||
<field name="last_error" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Payload">
|
||||
<field name="payload_preview" nolabel="1" readonly="1" widget="text"/>
|
||||
</page>
|
||||
<page string="Response">
|
||||
<field name="result" nolabel="1" readonly="1" widget="text"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="odoosh_sync_log_view_search" model="ir.ui.view">
|
||||
<field name="name">odoosh.sync.log.search</field>
|
||||
<field name="model">odoosh.sync.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Sync queue">
|
||||
<field name="login"/>
|
||||
<field name="external_id"/>
|
||||
<field name="error_code"/>
|
||||
<filter name="f_pending" string="Pending" domain="[('state', '=', 'pending')]"/>
|
||||
<filter name="f_failed" string="Failed" domain="[('state', '=', 'failed')]"/>
|
||||
<filter name="f_done" string="Done" domain="[('state', '=', 'done')]"/>
|
||||
<filter name="f_skipped" string="No change" domain="[('state', '=', 'skipped')]"/>
|
||||
<separator/>
|
||||
<filter name="f_upsert" string="Create / Update" domain="[('operation', '=', 'upsert')]"/>
|
||||
<filter name="f_deactivate" string="Deactivate" domain="[('operation', '=', 'deactivate')]"/>
|
||||
<filter name="f_delete" string="Delete" domain="[('operation', '=', 'delete')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="g_state" string="Status" context="{'group_by': 'state'}"/>
|
||||
<filter name="g_op" string="Operation" context="{'group_by': 'operation'}"/>
|
||||
<filter name="g_code" string="Error code" context="{'group_by': 'error_code'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_odoosh_sync_log" model="ir.actions.act_window">
|
||||
<field name="name">Sync queue</field>
|
||||
<field name="res_model">odoosh.sync.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_f_failed': 1, 'search_default_f_pending': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">The sync queue is empty</p>
|
||||
<p>Creating, updating, archiving or deleting a user adds a row here, processed in the background.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.odoosh</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[@name='integration']" position="after">
|
||||
<block title="OdooshCN Lab Environments" name="odoosh_settings">
|
||||
|
||||
<setting id="odoosh_enable" string="Enable sync"
|
||||
help="Once on, creating, updating, archiving and deleting users is pushed to OdooshCN in the background.">
|
||||
<field name="odoosh_enabled"/>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_connection" string="Platform connection" invisible="not odoosh_enabled"
|
||||
help="The token is created by an OdooshCN super administrator and stored on the server only. For local testing use http://127.0.0.1:port rather than localhost.">
|
||||
<div class="content-group">
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_base_url" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_base_url" placeholder="https://lab.school.edu"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_token" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_token" password="True" placeholder="Token starting with osh_"/>
|
||||
</div>
|
||||
<div class="mt8">
|
||||
<button name="action_odoosh_test" type="object"
|
||||
string="Test connection" class="btn-link" icon="fa-plug"/>
|
||||
</div>
|
||||
<field name="odoosh_token_set" invisible="1"/>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_scope" string="How syncing works" invisible="not odoosh_enabled"
|
||||
help="It takes effect as soon as the connection above is filled in; there is nothing to set up per user.">
|
||||
<div class="text-muted small">
|
||||
<div>New user: synced immediately, and its sync switch is turned on</div>
|
||||
<div>Updated user: synced when the switch is on, ignored when it is off</div>
|
||||
<div>Archived user: the platform account is deactivated</div>
|
||||
<div>Deleted user: the platform account is deleted, its environments go to the platform recycle bin</div>
|
||||
<div>Role, quotas and menus per user live on the Lab Environment tab of the user form</div>
|
||||
<div>Portal users and built-in Odoo accounts are never synced</div>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_env_defaults" string="Environment defaults" invisible="not odoosh_enabled"
|
||||
help="Used when an environment is provisioned automatically. Memory limit times the number of concurrent users should stay below the server memory.">
|
||||
<div class="content-group">
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_tenant" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_tenant" placeholder="Empty = the organisation bound to the token"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_version" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_version" placeholder="18"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_lang" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_lang" placeholder="en_US"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_mem_mb" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_mem_mb" placeholder="Empty = platform default"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="odoosh_default_sleep_hours" class="col-lg-4 o_light_label"/>
|
||||
<field name="odoosh_default_sleep_hours" placeholder="0 = never sleep"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
<setting id="odoosh_actions" string="Maintenance" invisible="not odoosh_enabled"
|
||||
help="After the first setup, or after changing the defaults, push every existing user once.">
|
||||
<div class="mt8">
|
||||
<button name="action_odoosh_sync_all" type="object"
|
||||
string="Sync all users" class="btn-link" icon="fa-refresh"/>
|
||||
<button name="action_odoosh_open_logs" type="object"
|
||||
string="Open sync queue" class="btn-link" icon="fa-list"/>
|
||||
</div>
|
||||
</setting>
|
||||
|
||||
</block>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- User form: the Lab Environment tab holds every platform permission; edit and it syncs -->
|
||||
<record id="view_users_form_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.form.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Lab Environment" name="odoosh"
|
||||
groups="base.group_system,odoosh_connector.group_odoosh_teacher">
|
||||
<group>
|
||||
<group string="Synchronisation">
|
||||
<field name="odoosh_sync_enabled"/>
|
||||
<field name="odoosh_sync_state"/>
|
||||
<field name="odoosh_synced_at"/>
|
||||
<field name="odoosh_platform_username"/>
|
||||
</group>
|
||||
<group string="Actions">
|
||||
<div colspan="2" class="d-flex gap-2 flex-wrap">
|
||||
<button name="action_odoosh_sync_now" type="object" string="Sync now"
|
||||
class="btn-primary" icon="fa-refresh" invisible="not odoosh_sync_enabled"/>
|
||||
</div>
|
||||
<div colspan="2" class="text-muted small mt-2">
|
||||
New users are synced automatically. Switching this off stops every push for
|
||||
this user and leaves the platform account as it is.
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Last error" invisible="not odoosh_last_error">
|
||||
<field name="odoosh_last_error" nolabel="1" readonly="1"/>
|
||||
</group>
|
||||
<group invisible="not odoosh_sync_enabled">
|
||||
<group string="Identity and roles">
|
||||
<field name="odoosh_username" placeholder="Empty = derived from the login"/>
|
||||
<field name="odoosh_tenant" placeholder="Empty = default organisation"/>
|
||||
<field name="odoosh_role"/>
|
||||
<field name="odoosh_tenant_role"/>
|
||||
</group>
|
||||
<group string="Permissions and quotas">
|
||||
<field name="odoosh_shell_enabled"/>
|
||||
<field name="odoosh_can_use_enterprise"/>
|
||||
<field name="odoosh_auto_provision"/>
|
||||
<field name="odoosh_max_envs"/>
|
||||
<field name="odoosh_max_dbs"/>
|
||||
</group>
|
||||
<group string="Platform menus">
|
||||
<field name="odoosh_menu_dashboard"/>
|
||||
<field name="odoosh_menu_repos"/>
|
||||
<field name="odoosh_menu_ai"/>
|
||||
<field name="odoosh_menu_backups"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_users_tree_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.list.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='login_date']" position="after">
|
||||
<field name="odoosh_sync_enabled" optional="hide"/>
|
||||
<field name="odoosh_sync_state" optional="hide"/>
|
||||
<field name="odoosh_tenant_role" optional="hide"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_users_search_odoosh" model="ir.ui.view">
|
||||
<field name="name">res.users.search.inherit.odoosh</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='Inactive']" position="after">
|
||||
<separator/>
|
||||
<filter name="odoosh_failed" string="Lab sync failed" domain="[('odoosh_sync_state', '=', 'failed')]"/>
|
||||
<filter name="odoosh_pending" string="Lab sync pending" domain="[('odoosh_sync_state', '=', 'pending')]"/>
|
||||
<filter name="odoosh_disabled" string="Lab sync disabled" domain="[('odoosh_sync_enabled', '=', False)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Bulk actions from the user list -->
|
||||
<record id="action_server_odoosh_sync_now" model="ir.actions.server">
|
||||
<field name="name">Sync to lab platform</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_sync_now()</field>
|
||||
</record>
|
||||
|
||||
<record id="action_server_odoosh_enable" model="ir.actions.server">
|
||||
<field name="name">Enable lab sync</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_enable_sync()</field>
|
||||
</record>
|
||||
|
||||
<record id="action_server_odoosh_disable" model="ir.actions.server">
|
||||
<field name="name">Disable lab sync</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="groups_id" eval="[(4, ref('odoosh_connector.group_odoosh_teacher')), (4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_odoosh_disable_sync()</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user