Compare commits

...

No commits in common. "0.2.1" and "master" have entirely different histories.

43 changed files with 12257 additions and 142 deletions

5
.dockerignore 100644
View File

@ -0,0 +1,5 @@
/dist/*
/bot.z_web/node_modules/*
/.*yaml
/.*yml
/Makefile

View File

@ -1,15 +0,0 @@
* Bot_Z version:
* Python version:
* Operating System:
### Description
Describe what you were trying to get done.
Tell us what happened, what went wrong, and what you expected to happen.
### What I Did
```
Paste the command(s) you ran and the output.
If there was a crash, please include the traceback here.
```

5
.gitignore vendored
View File

@ -1,5 +1,8 @@
# Postinstall bineries
# Postinstall binaries and assets
/bot_z/bin/*
/api/assets/*
# debug config file
/.botz.yaml
# Byte-compiled / optimized / DLL files
__pycache__/

36
Dockerfile 100644
View File

@ -0,0 +1,36 @@
FROM node AS node
FROM selenium/standalone-firefox AS ff
FROM python:3.7-buster
LABEL author="Blallo"
LABEL email="blallo@autistici.org"
LABEL io.troubles.botz.release-date="2019-09-25"
LABEL io.troubles.botz.version="1.1.3"
ENV DEBIAN_FRONTEND=noninteractive
COPY --from=node /usr/local /usr/local
COPY --from=node /opt /opt
COPY --from=ff /opt/firefox-latest /opt/firefox-latest
COPY --from=ff /opt/geckodriver* /opt/
COPY . /app
COPY entrypoint.sh /srv/
WORKDIR /app/bot.z_web
RUN apt-get update \
&& apt-get install -y $(apt-cache depends firefox-esr| awk '/Depends:/{print$2}') \
&& ln -s /opt/firefox-latest/firefox /usr/bin/firefox \
&& ln -s /opt/geckodriver* /usr/bin/geckodriver \
&& yarn install \
&& yarn build \
&& mkdir -p /app/api/assets \
&& cp -r build/* /app/api/assets/ \
&& rm -r node_modules/ /var/lib/apt/lists/* /var/cache/apt/* /tmp/*
WORKDIR /app
RUN python setup.py develop
EXPOSE 3003
VOLUME ["/app/bot.z_web/node_modules"]
ENTRYPOINT ["/srv/entrypoint.sh"]

View File

@ -1,40 +1,16 @@
.PHONY: clean clean-test clean-pyc clean-build docs help
.DEFAULT_GOAL := help
define BROWSER_PYSCRIPT
import os, webbrowser, sys
try:
from urllib import pathname2url
except:
from urllib.request import pathname2url
.PHONY: clean clean-test clean-pyc clean-build
webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1])))
endef
export BROWSER_PYSCRIPT
define PRINT_HELP_PYSCRIPT
import re, sys
for line in sys.stdin:
match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line)
if match:
target, help = match.groups()
print("%-20s %s" % (target, help))
endef
export PRINT_HELP_PYSCRIPT
BROWSER := python -c "$$BROWSER_PYSCRIPT"
help:
@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
VERSION = 1.1.3
clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts
clean-build: ## remove build artifacts
rm -fr build/
rm -fr dist/
rm -fr .eggs/
find . -name '*.egg-info' -exec rm -fr {} +
find . -name '*.egg' -exec rm -f {} +
rm -fr bot_z.web/build/
clean-pyc: ## remove Python file artifacts
find . -name '*.pyc' -exec rm -f {} +
@ -47,41 +23,41 @@ clean-test: ## remove test and coverage artifacts
rm -f .coverage
rm -fr htmlcov/
lint: ## check style with flake8
flake8 bot_z tests
release: clean build docker-release
test: ## run tests quickly with the default Python
python setup.py test
test-all: ## run tests on every Python version with tox
tox
coverage: ## check code coverage quickly with the default Python
coverage run --source bot_z setup.py test
coverage report -m
coverage html
$(BROWSER) htmlcov/index.html
docs: ## generate Sphinx HTML documentation, including API docs
rm -f docs/bot_z.rst
rm -f docs/modules.rst
sphinx-apidoc -o docs/ bot_z
$(MAKE) -C docs clean
$(MAKE) -C docs html
$(BROWSER) docs/_build/html/index.html
servedocs: docs ## compile the docs watching for changes
watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D .
release: clean ## package and upload a release
python setup.py sdist upload
python setup.py bdist_wheel upload
dist: clean ## builds source and wheel package
sdist:
python setup.py sdist
python setup.py bdist_wheel
ls -l dist
build: sdist build-linux64 build-linux32 build-win32 build-win64 build-macos
build-linux64:
python setup.py bdist_wheel --plat-name manylinux1-x86_64
sdist/bot_z-$(VERSION)-py3-none-manylinux1_x86_64.whl: build-linux64
build-linux32:
python setup.py bdist_wheel --plat-name manylinux1-i686
sdist/bot_z-$(VERSION)-py3-none-manylinux1_i686.whl: build-linux32
build-win32:
python setup.py bdist_wheel --plat-name win32
sdist/bot_z-$(VERSION)-py3-none-win32.whl: build-win32
build-win64:
python setup.py bdist_wheel --plat-name win-amd64
sdist/bot_z-$(VERSION)-py3-none-manylinux1_win-amd64.whl: build-win64
build-macos:
python setup.py bdist_wheel --plat-name macosx
sdist/bot_z-$(VERSION)-py3-none-macosx.whl: build-macos
install: clean ## install the package to the active Python's site-packages
python setup.py install
docker-release:
docker build -t botz:latest .
docker tag botz:latest botz:$(VERSION)

View File

@ -13,6 +13,62 @@ Features
* Login/Logout
* Check in/Check out
* web ui
Develop
-------
To allow local development you either need a configuration file named
`.botz.yaml` in the root of the project with something like this:
```
---
base_uri: "<your_target_uri>"
debug: true
headless: true
log:
level: DEBUG
syslog: false
http:
bind_addr:
- "127.0.0.1"
port: 3003
cookie_secure: false
cors_allow: "http://localhost:3000"
```
Read the docstring in `api/conf.py` to understand the menaning of the
various parameters.
*Do not `pip install -e .`*. It will miss the geckodriver download step.
Take a look at the provided `Dockerfile`.
You can either run it:
```
$ docker build -t botz:latest .
$ docker run -v $PWD:/app -p "3003:3003" botz
```
and find a working app at `http://localhost:3003`.
Or you can `python setup.py bdist_wheel && python setup.py develop`.
You will need:
- `python >= 3.7`
- `yarn` (`npm` support coming soon...)
If you want to develop the ui, you can also serve it via yarn (as it
supports hot reload):
```
$ cd bot.z_web
$ yarn start
```
You will find a working ui at `localhost:3000` (but you need the last two
lines of the previous example config file).
Install
@ -35,5 +91,5 @@ TODO
- [x] Check in/out
- [ ] systemd {unit, timer}
- [ ] APIs
- [x] APIs
- [ ] Mailer

7
api/__init__.py 100644
View File

@ -0,0 +1,7 @@
# -*- encoding: utf-8 -*-
from distutils.util import strtobool
import os
# TODO: create config module and put this constant there.
BASE_URI = os.environ.get("BOTZ_BASE_URI", "http://localhost")
DEBUG = strtobool(os.environ.get("BOTZ_DEBUG", "False"))

108
api/app.py 100644
View File

@ -0,0 +1,108 @@
# -*- encoding: utf-8 -*-
"""
The application entrypoint.
"""
from aiohttp import web
import base64
from cryptography import fernet
import logging
import logging.handlers
import os
import typing as T
import click
from aiohttp_session import setup, session_middleware
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from bot_z.async_operator import AsyncOperator
from api.rest import routes, add_static_routes
from api.conf import read_conf
def setup_log(level: str, syslog: bool) -> logging.Logger:
alog = logging.getLogger("api")
alog.setLevel(os.environ.get("BOTZ_LOGLEVEL", level))
h = logging.StreamHandler()
f = logging.Formatter("%(levelname)s [%(name)s] -> %(message)s")
h.setFormatter(f)
alog.addHandler(h)
if syslog:
sh = logging.handlers.SysLogHandler()
alog.addHandler(sh)
return alog
def init_secret() -> bytes:
fernet_key = fernet.Fernet.generate_key()
return base64.urlsafe_b64decode(fernet_key)
def setup_session(app: web.Application, secure: bool, max_age: int):
secret = init_secret()
setup(
app,
EncryptedCookieStorage(
secret_key=secret,
cookie_name="BOTZ_SESSION",
httponly=False,
secure=secure,
max_age=max_age,
),
)
def run(
address: T.Optional[T.Text], port: T.Optional[int], conf_path: T.Optional[T.Text]
) -> None:
"""Application entrypoint."""
conf = read_conf(conf_path)
# This closure is needed to intercept the to-be-prepared response
# and add the right CORS headers
async def on_prepare_cors(request, response):
response.headers["Access-Control-Allow-Origin"] = conf["http"].get("cors_allow")
response.headers["Access-Control-Allow-Credentials"] = "true"
alog = setup_log(conf["log"]["level"], conf["log"]["syslog"])
alog.debug("conf %s", conf)
app = web.Application(logger=alog)
app["base_uri"] = conf["base_uri"]
app["debug"] = conf["debug"]
app["headless"] = conf["headless"]
if conf["http"].get("cors_allow"):
app.on_response_prepare.append(on_prepare_cors)
setup_session(app, conf["http"]["cookie_secure"], conf["http"]["session_timeout"])
add_static_routes(alog)
app.add_routes(routes)
addr = []
if address is not None:
addr = [address]
elif conf["http"].get("bind_addr"):
addr.extend(conf["http"]["bind_addr"])
else:
addr = ["127.0.0.1"]
if port is None:
port = conf["http"]["port"]
alog.debug("Starting app with: addr -> %s, port -> %d", addr, port)
web.run_app(app, host=addr, port=port)
@click.command()
@click.option(
"-a", "--address", type=click.STRING, help="Address to bind the server to."
)
@click.option("-p", "--port", type=click.INT, help="Port to bind to.")
@click.option(
"-c",
"--conf",
type=click.Path(exists=False),
help="A path to a configuration file.",
)
def cli(
address: T.Optional[T.Text],
port: T.Optional[int] = None,
conf: T.Optional[T.Text] = None,
) -> None:
run(address, port, conf)

92
api/async_bot.py 100644
View File

@ -0,0 +1,92 @@
# -*- encoding: utf-8 -*-
"""
Asyncio wrapper for AsyncOperator methods.
"""
import asyncio
from contextlib import contextmanager
import typing as T
from bot_z.async_operator import AsyncOperator
from bot_z.exceptions import OperationFailed
@contextmanager
def this_loop(loop):
_loop = asyncio.get_event_loop()
asyncio.set_event_loop(loop)
yield
asyncio.set_event_loop(_loop)
def init(base_url: T.Text, name: T.Text) -> AsyncOperator:
"""
Initialize the stateful object.
"""
return AsyncOperator(base_url, name)
async def login(op: AsyncOperator, username: T.Text, password: T.Text) -> bool:
"""
Executes the login asynchronously and returns
the AsyncOperator object.
"""
if not op.logged_in:
try:
await op.login(username, password)
except OperationFailed:
pass
return op.logged_in
async def logout(op: AsyncOperator) -> bool:
"""
Executes the logout asynchronously and returns
a bool showing the success or failure of the operation.
"""
if op.logged_in:
try:
await op.logout()
except OperationFailed:
pass
return op.logged_in
async def checkin(op: AsyncOperator) -> bool:
"""
Executes the check in asynchronously and returns
a bool showing the success or failure of the operation.
"""
if op.logged_in and not op.checked_in:
try:
await op.checkin()
except OperationFailed:
pass
return op.checked_in
async def checkout(op: AsyncOperator) -> bool:
"""
Executes the checkout asynchronously and returns
a bool showing the success or failure of the operation.
"""
if op.logged_in and op.checked_in:
try:
await op.checkout()
except OperationFailed:
pass
return op.checked_in
async def status(op: AsyncOperator) -> T.List[T.Optional[T.Tuple[T.Text, T.Text]]]:
"""
Asks the list of movements asynchronously and returns a list
of tuples containing the succession of movements. If the operation
fails, the list is empty.
"""
movements = []
if op.logged_in:
res = await op.get_movements()
movements.extend(res)
return movements

99
api/conf.py 100644
View File

@ -0,0 +1,99 @@
# -*- encoding: utf-8 -*-
import os
from pprint import pprint
import typing as T
import yaml
from api import DEBUG, BASE_URI
def read_conf(path: T.Optional[T.Text]) -> T.Dict:
"""
Read the configuration from the provided path.
Such configuration may provide the following information:
---
base_uri: <base uri of the target instance>
debug: <bool, set debug on, defaults to false>
headless: <bool, use headless mode, defaults to true>
log:
level: <may be DEBUG, INFO, WARN, ..., defaults at INFO>
syslog: <bool, whether to redirect to standard syslog, defaults to false>
http:
bind_addr: <a list of addresses to bind to>
port: <int, the port to bind to>
cookie_name: <defaults to BOTZ_SESSION>
cookie_secure: <bool, whether to set Secure cookie flag, defaults to true>
session_timeout: <int, the expiration time of the session ins secs, defaults to 300>
cors_allow: <an optional single allowed Cross Origin domain>
"""
if path is None:
path = seek_path()
if path is not None:
with open(path) as f:
conf = yaml.safe_load(f)
else:
conf = {}
if "base_uri" not in conf:
if BASE_URI is None:
raise RuntimeError("Missing base_uri")
conf["base_uri"] = BASE_URI
if "debug" not in conf:
conf["debug"] = DEBUG
if "headless" not in conf:
conf["headless"] = True
conf = validate_log_conf(conf)
conf = validate_http_log(conf)
pprint(conf)
return conf
def seek_path() -> T.Optional[T.Text]:
"""
Seeks the path to a config file, in the following order:
- $BOTZ_CONFIG
- $PWD/.botz.yaml
- ~/.botz.yaml
- /etc/botz/conf.yaml
"""
paths = [
os.path.join(os.path.curdir, ".botz.yaml"),
os.path.expanduser("~/.botz.yaml"),
"/etc/botz/conf.yaml",
]
env = os.environ.get("BOTZ_CONFIG")
if env is not None:
paths.insert(0, env)
for path in paths:
if os.path.exists(path):
return path
return None
def validate_log_conf(conf: T.Dict[T.Text, T.Any]) -> T.Dict[T.Text, T.Any]:
if "log" not in conf:
conf["log"] = {}
if conf["log"].get("level") is None:
conf["log"]["level"] = "INFO"
if conf["log"].get("syslog") is None:
conf["log"]["syslog"] = False
return conf
def validate_http_log(conf: T.Dict[T.Text, T.Any]) -> T.Dict[T.Text, T.Any]:
if "http" not in conf:
conf["http"] = {}
if conf["http"].get("bind_addr") is None:
conf["http"]["bind_addr"] = ["127.0.0.1"]
if conf["http"].get("port") is None:
conf["http"]["port"] = 3003
if conf["http"].get("cookie_name") is None:
conf["http"]["cookie_name"] = "BOTZ_SESSION"
if conf["http"].get("cookie_secure") is None:
conf["http"]["cookie_secure"] = True
if conf["http"].get("session_timeout") is None:
conf["http"]["session_timeout"] = 300
elif isinstance(conf["http"]["session_timeout"], str):
conf["http"]["session_timeout"] = int(conf["http"]["session_timeout"])
return conf

82
api/dev_server.py 100644
View File

@ -0,0 +1,82 @@
# -*- encoding: utf-8 -*-
from aiohttp import web
import logging
import click
logging.basicConfig(
format="%(message)s", level=logging.INFO, handlers=[logging.StreamHandler()]
)
alog = logging.getLogger(__name__)
routes = web.RouteTableDef()
@routes.get("/{tail:.*}")
async def get_handler(request: web.Request) -> web.Response:
alog.info("GET -> [%s]: %s", request.path, request.query)
return web.json_response(
{"method": request.method, "path": request.path},
headers={"Access-Control-Allow-Origin": "*"},
)
@routes.post("/{tail:.*}")
async def post_handler(request: web.Request) -> web.Response:
data = await request.post()
alog.info("POST -> [%s]: %s", request.path, data)
return web.json_response(
{"method": request.method, "path": request.path},
headers={"Access-Control-Allow-Origin": "*"},
)
@routes.put("/{tail:.*}")
async def put_handler(request: web.Request) -> web.Response:
data = await request.post()
alog.info("PUT -> [%s]: %s", request.path, data)
return web.json_response(
{"method": request.method, "path": request.path},
headers={"Access-Control-Allow-Origin": "*"},
)
@routes.delete("/{tail:.*}")
async def delete_handler(request: web.Request) -> web.Response:
data = await request.post()
alog.info("DELETE -> [%s]: %s", request.path, data)
return web.json_response(
{"method": request.method, "path": request.path},
headers={"Access-Control-Allow-Origin": "*"},
)
async def options_handler(request: web.Request) -> web.Response:
alog.info("OPTIONS -> [%s]", request.path)
return web.Response(status=200)
def run(address: str, port: int) -> None:
"""Application entrypoint."""
app = web.Application(logger=alog)
app.add_routes(routes)
app.router.add_route("OPTIONS", "/{tail:.*}", options_handler)
web.run_app(app, host=address, port=port)
@click.command()
@click.option(
"-a",
"--address",
type=click.STRING,
help="Address to bind the server to.",
default="127.0.0.1",
)
@click.option("-p", "--port", type=click.INT, help="Port to bind to", default=3003)
def cli(address: str, port: int) -> None:
run(address, port)
if __name__ == "__main__":
cli()

224
api/rest.py 100644
View File

@ -0,0 +1,224 @@
# -*- encoding: utf-8 -*-
"""
The REST endpoints.
"""
from aiohttp import web
import asyncio
from concurrent.futures import ProcessPoolExecutor
import datetime
import logging
import os
import pkg_resources
import typing as T
import weakref
from aiohttp_session import new_session, get_session, Session
from passlib.hash import bcrypt
from bot_z.async_operator import AsyncOperator, push_to_loop
from api.async_bot import login, logout, checkin, checkout, status
from api import BASE_URI, DEBUG
alog = logging.getLogger("api")
routes = web.RouteTableDef()
OPERATORS = weakref.WeakKeyDictionary(
{}
) # type: weakref.WeakKeyDictionary[UserSession, AsyncOperator]
USERS = {} # type: T.Dict[T.Text, UserSession]
BASE_PATH = pkg_resources.resource_filename(__name__, "assets")
EXECUTOR = ProcessPoolExecutor()
# WARN: the default il 12 rounds; both the server and the client shall compute
# this hash. The target client is a smartphone with poor performance on
# crypto computations, so we should keep this low, as the verification step
# is optionally enforced by the client.
ROUNDS = 6
class UserSession(object):
"""
Placeholder object to manipulate session life.
"""
def __init__(self, user):
self._user = user
def _reckon_token_response(base_uri: T.Text) -> T.Text:
return bcrypt.using(rounds=ROUNDS, truncate_error=True).hash(base_uri)
async def reckon_token_response(
base_uri: T.Text, loop: asyncio.AbstractEventLoop
) -> T.Text:
"""
A client and the server should agree if the pairing is adequate.
This could be accomplished calculating on both sides an cryptographic
secret. The current implementation uses bcrypt to compute the hash on
server side and the client should verify the secret on its side.
"""
return await push_to_loop(loop, EXECUTOR, _reckon_token_response, base_uri)
async def get_set_operator(
request: web.Request, user: T.Text, password: T.Text
) -> T.Tuple[AsyncOperator, Session]:
session = await get_session(request)
op = None
if "async_operator" in session:
user_session = USERS.get(session["async_operator"])
op = OPERATORS.get(user_session)
else:
session = await new_session(request)
if op is None or session.new:
base_uri = request.app["base_uri"]
debug = request.app["debug"]
headless = request.app["headless"]
op = AsyncOperator(base_uri, name=user, headless=headless, debug=debug)
USERS[user] = UserSession(user)
session["async_operator"] = user
OPERATORS[USERS[user]] = op
return op, session
def add_static_routes(log: logging.Logger) -> None:
static_assets = [
os.path.abspath(os.path.join(BASE_PATH, path))
for path in os.listdir(BASE_PATH)
if os.path.isdir(os.path.join(BASE_PATH, path))
]
for asset in static_assets:
asset_path = os.path.relpath(asset, BASE_PATH)
log.debug(f"Linking: {asset_path} -> {asset}")
routes.static(f"/{asset_path}", asset)
@routes.get("/")
async def home_handle(request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(BASE_PATH, "index.html"))
@routes.get("/favicon.ico")
async def favicon_handle(request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(BASE_PATH, "favicon.ico"))
@routes.get("/api/login")
@routes.get("/api/badge")
@routes.get("/api/status")
async def routing_handler(request: web.Request) -> web.Response:
alog.debug("(%s) %s", request.method, request.path)
session = await get_session(request)
op = session.get("async_operator")
_logged_in = True if op else False
alog.info("%s - is%s in session", request.path, " NOT" if not _logged_in else "")
return web.json_response({"logged_in": _logged_in})
@routes.get("/api/ping")
async def ping_handler(request: web.Request) -> web.Response:
alog.debug("pinged on %s", request.path)
resp_data = await reckon_token_response(request.app["base_uri"], request.app.loop)
alog.debug("ping response: %s", resp_data)
return web.json_response({"hash": resp_data})
@routes.post("/api/login")
async def login_handler(request: web.Request) -> web.Response:
data = await request.json()
user = data.get("username")
password = data.get("password")
if not user or not password:
alog.debug("login - missing username or password: %s", data)
return web.json_response({"error": "Missing username or password"}, status=401)
op, session = await get_set_operator(request, user, password)
alog.debug("login - user: %s, password: %s", user, password)
res = await login(op, user, password)
alog.debug("login result: %s", res)
if not res:
session.invalidate()
alog.info("Login failed; session invalidated.")
return web.json_response({"logged_in": res}, status=200)
@routes.post("/api/logout")
async def logout_handler(request: web.Request) -> web.Response:
alog.debug("logout")
session = await get_session(request)
user_session = USERS.get(session["async_operator"], UserSession("NOONE"))
op = OPERATORS.get(user_session)
if not op:
return web.json_response(
{"error": "No session", "logged_in": False}, status=401
)
res = await logout(op)
session.invalidate()
alog.debug("logout result: %s", res)
# FIX: assess if better to invalidate session and dump the browser instance.
del user_session
return web.json_response({"logged_in": res}, status=200)
@routes.post("/api/checkin")
async def checkin_handler(request: web.Request) -> web.Response:
alog.debug("checkin")
session = await get_session(request)
user_session = USERS.get(session["async_operator"], UserSession("NOONE"))
op = OPERATORS.get(user_session)
if not op:
return web.json_response(
{"error": "No session", "logged_in": False}, status=401
)
res = await checkin(op)
alog.debug("checkin result: %s", res)
return web.json_response({"checked_in": res, "logged_in": True}, status=200)
@routes.post("/api/checkout")
async def checkout_handler(request: web.Request) -> web.Response:
alog.debug("checkout")
session = await get_session(request)
user_session = USERS.get(session["async_operator"], UserSession("NOONE"))
op = OPERATORS.get(user_session)
if not op:
return web.json_response(
{"error": "No session", "logged_in": False}, status=401
)
res = await checkout(op)
alog.debug("checkout result: %s", res)
return web.json_response({"checked_in": res, "logged_in": True}, status=200)
@routes.get("/api/movements")
async def movements_handle(request: web.Request) -> web.Response:
alog.debug("movements")
session = await get_session(request)
user_session = USERS.get(session.get("async_operator"), UserSession("NOONE"))
op = OPERATORS.get(user_session)
if not op:
alog.debug("Missing session")
return web.json_response(
{"error": "No session", "logged_in": False}, status=401
)
res = await status(op)
alog.debug("movements result: %s", res)
if not res:
return web.json_response(
{"error": "No movements found", "logged_in": True, "checked_in": False},
status=404,
)
movements = []
for r in res:
if r and len(r) == 2:
movements.append({"time": r[1], "type": r[0]})
resp_data: T.Dict[T.Text, T.Any] = {"movements": movements}
resp_data["logged_in"] = True
try:
last_movement = list(movements[-1])
resp_data["checked_in"] = True if last_movement == "Entrata" else False
except IndexError:
alog.info("No movements found")
return web.json_response(resp_data, status=200)

23
bot.z_web/.gitignore vendored 100644
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -0,0 +1,68 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.<br>
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.<br>
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
### Analyzing the Bundle Size
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
### Making a Progressive Web App
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
### Advanced Configuration
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
### Deployment
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
### `npm run build` fails to minify
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify

View File

@ -0,0 +1,34 @@
{
"name": "bot.z_web",
"version": "1.0.0",
"private": true,
"dependencies": {
"bootstrap": "^4.3.1",
"react": "^16.8.6",
"react-bootstrap": "^1.0.0-beta.10",
"react-dom": "^16.8.6",
"react-router-dom": "^5.0.1",
"react-scripts": "3.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>BotZ</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "BotZ",
"name": "BotZ web interface",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,45 @@
body {
padding-top: 15%;
padding-bottom: 20%;
background-color: #eee;
}
#main-form {
max-width: 330px;
padding: 15px;
margin: 0 auto;
}
.App {
text-align: center;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 40vmin;
pointer-events: none;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,28 @@
import React from 'react';
import IndexPage from './IndexPage.js';
class App extends React.Component {
constructor(props) {
super(props)
this.state = {targetUrl: props.targetUrl, loggedIn: false}
}
componentDidMount() {
fetch(this.targetUrl + '/status', {credentials: 'include'})
.then(response => response.json())
.then(data => this.setState({loggedIn: data.logged_in}));
}
render() {
return (
<div id="main" className="container">
<IndexPage targetUrl={this.state.targetUrl} loggedIn={this.state.loggedIn} />
</div>
);
}
}
export default App;

View File

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

View File

@ -0,0 +1,101 @@
import React from 'react';
import ModalBox from './ModalBox.js';
import { Button, Spinner } from 'react-bootstrap';
import './App.css';
function isCheckedIn(movements) {
if (movements.length === 0) {
return false;
}
const lastMov = movements[movements.length - 1].type;
if (lastMov === "Entrata") {
return true;
} else {
return false;
}
}
class BadgePage extends React.Component {
constructor(props) {
super(props);
this.state = {loggedIn: props.loggedIn, checkedIn: false, loading: false};
}
_isMounted = false
componentDidMount() {
this._isMounted = true;
fetch(this.props.targetUrl + '/movements', {credentials: 'include'})
.then(response => response.json())
.then(data => {if (this._isMounted) {this.setState({
checkedIn: isCheckedIn(data.movements), loggedIn: data.logged_in
})}})
.catch(error => console.log(error));
}
componentWillUnmount() {
this._isMounted = false;
}
handleToggle = (event) => {
this.setState({loading: true})
var path;
if (this.state.checkedIn) {
path = '/checkout';
} else {
path = '/checkin';
}
fetch(this.props.targetUrl + path, {
method: 'POST',
credentials: 'include'
})
.then(response => response.json())
.then(data => this.setState({
checkedIn: data.checked_in,
loggedIn: data.logged_in,
loading: false
}));
}
buttonValue() {
if (this.state.checkedIn) {
return "CHECKOUT";
} else {
return "CHECKIN";
}
}
dynButton() {
const badge = (
<ModalBox
title="Badge"
data={<Button variant="primary" type="button" onClick={this.handleToggle}>{this.buttonValue()}</Button>}
/>
);
const loading =
<ModalBox
title="Badge"
data=<Spinner animation="border" variant="primary" />
/>;
if (this.state.loading) {
return loading;
} else {
return badge;
}
}
render() {
if (this.state.loggedIn) {
return this.dynButton();
} else {
return (<ModalBox title="Badge" data=<h2>You have to login first!</h2> />);
}
}
}
export default BadgePage;

View File

@ -0,0 +1,69 @@
import React from 'react';
import { BrowserRouter as Router, Route, Link, Switch } from "react-router-dom";
import { Nav, Navbar, Container } from "react-bootstrap";
import LoginForm from './LoginForm.js';
import MovementsPage from './Movements.js';
import BadgePage from './Badge.js';
import LogoffPage from './Logoff.js';
class IndexPage extends React.Component {
constructor(props) {
super(props);
this.state = {loggedIn: props.loggedIn};
}
componentDidMount() {
fetch(this.props.targetUrl + '/status', {credentials: 'include'})
.then(response => response.json())
.then(data => this.setState({loggedIn: data.logged_in}))
.catch(error => {console.log(error)});
}
render() {
return (
<Router>
<Navbar bg="light" expand="lg">
<Navbar.Brand href="/">BotZ</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav variant="tabs" className="mr-auto">
<Nav.Item>
<Nav.Link as='div' eventKey="login">
<Link to='/login'>Login</Link>
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link as='div' eventKey="movements">
<Link to='/movements'>Movements</Link>
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link as='div' eventKey="badge">
<Link to='/badge'>Badge</Link>
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link as='div' eventKey="logout">
<Link to='/logout'>Logout</Link>
</Nav.Link>
</Nav.Item>
</Nav>
</Navbar.Collapse>
</Navbar>
<div>
<Container>
<Switch>
<Route path="/login" component={() => <LoginForm targetUrl={this.props.targetUrl} loggedIn={this.state.loggedIn} />} />
<Route path="/movements" component={() => <MovementsPage targetUrl={this.props.targetUrl} loggedIn={this.state.loggedIn} />} />
<Route path="/badge" component={() => <BadgePage targetUrl={this.props.targetUrl} loggedIn={this.state.loggedIn} />} />
<Route path="/logout" component={() => <LogoffPage targetUrl={this.props.targetUrl} loggedIn={this.state.loggedIn} />} />
</Switch>
</Container>
</div>
</Router>
);
}
}
export default IndexPage;

View File

@ -0,0 +1,111 @@
import React from 'react';
import { Form, Button, Spinner } from 'react-bootstrap';
import ModalBox from './ModalBox.js';
import './App.css';
class LoginForm extends React.Component {
constructor(props) {
super(props);
this.state = {
user: '', password: '',
loggedIn: props.loggedIn,
loading: false,
error: false
};
}
componentDidUpdate() {
if (this.state.error) {
console.log(this.state);
this.timeout = setTimeout(this.resetComponentState, 3000);
}
}
componentDidMount() {
fetch(this.props.targetUrl + '/status', {credentials: 'include'})
.then(response => response.json())
.then(data => this.setState({
loggedIn: data.logged_in
})
);
}
componentWillUnmount() {
clearTimeout(this.timeout)
}
handleUserChange = (event) => {
this.setState({user: event.target.value});
};
handlePasswordChange = (event) => {
this.setState({password: event.target.value});
};
handleSubmit = (event) => {
event.preventDefault();
this.setState({loading: true});
const formData = event.target.elements;
var data = {username: formData["username"].value, password: formData["password"].value};
fetch(this.props.targetUrl + '/login', {
method: 'POST',
body: JSON.stringify(data),
credentials: 'include'
})
.then(response => response.json())
.then(jsonData => this.setState({loggedIn: jsonData.logged_in, loading: false}))
.catch((error) => {console.log(error); this.setState({error: true})})
}
dynForm() {
const form =
<ModalBox
title=<h2>Login Form</h2>
data=
<Form onSubmit={this.handleSubmit}>
<Form.Group controlId="username">
<Form.Control onChange={this.handleUserChange} type="text" name="username" placeholder="Username" />
</Form.Group>
<Form.Group controlId="password">
<Form.Control onChange={this.handlePasswordChange} type="password" name="password" placeholder="Password" />
</Form.Group>
<Button variant="primary" type="submit">Send</Button>
</Form>
/>;
const loading =
<ModalBox
title="Login"
data=<Spinner animation="border" variant="primary" />
/>;
if (this.state.loading) {
return loading;
} else {
return form;
}
}
resetComponentState = () => {
this.setState({error: false, loading: false, username: '', password: ''})
}
render() {
if (this.state.loggedIn) {
return (<ModalBox title="Login" data=<h2>Yet logged in!</h2> />);
}
if (this.state.error) {
return (<div><h2>Error!</h2></div>);
} else {
return this.dynForm();
}
}
}
export default LoginForm;

View File

@ -0,0 +1,73 @@
import React from 'react';
import { Spinner, Button } from 'react-bootstrap';
import ModalBox from './ModalBox.js';
import './App.css';
import './bootstrap.min.css';
class LogoffPage extends React.Component {
constructor(props) {
super(props);
this.state = {logged_in: props.loggedIn, loading: false};
}
componentDidMount() {
fetch(this.props.targetUrl + '/status', {credentials: 'include'})
.then(response => response.json())
.then(data => this.setState({
loggedIn: data.logged_in
}));
}
handleToggle = (event) => {
this.setState({loading: true})
fetch(this.props.targetUrl + '/logout', {
method: 'POST',
credentials: 'include'
})
.then(response => response.json())
.then(data => this.setState({
loggedIn: data.logged_in, loading: false
}));
}
buttonValue() {
if (this.state.loggedIn) {
return "LOGOUT";
} else {
return "LOGIN";
}
}
dynButton() {
const logout = (
<ModalBox
title="Logoff"
data=<Button variant="primary" type="button" onClick={this.handleToggle}>{this.buttonValue()}</Button>
/>
);
const loading =
<ModalBox
title="Logout"
data=<Spinner animation="border" variant="primary" />
/>;
if (this.state.loading) {
return loading;
} else {
return logout;
}
}
render() {
if (this.state.loggedIn) {
return this.dynButton();
} else {
return (<ModalBox title="Logout" data=<h2>You have to login first!</h2> />);
}
}
}
export default LogoffPage;

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Modal } from 'react-bootstrap';
import './App.css';
class ModalBox extends React.Component {
render() {
return (
<Modal.Dialog>
<Modal.Header>
<Modal.Title>{this.props.title}</Modal.Title>
</Modal.Header>
<Modal.Body>
{this.props.data}
</Modal.Body>
</Modal.Dialog>
);
}
}
export default ModalBox;

View File

@ -0,0 +1,51 @@
import React from 'react';
import ModalBox from './ModalBox.js';
import './App.css';
const Empty = <ModalBox title="Movements" data=<h3>Empty</h3> />;
const Error = <ModalBox title="Movements" data=<h3>Error</h3> />;
class MovementsPage extends React.Component {
constructor(props) {
super(props);
this.state = {loggedIn: props.loggedIn, movements: []};
}
componentDidMount() {
fetch(this.props.targetUrl + '/movements', {credentials: 'include'})
.then(response => response.json())
.then(data => this.setState({movements: data.movements, loggedIn: data.logged_in}))
.catch(error => {console.log(error); this.setState({error: true})});
}
componentWillUnmount() {
this.setState({error: false, movements: []});
}
handleTable() {
if (this.state.movements !== undefined) {
const table =
<ul>{this.state.movements.map(movs => <li key={movs.time}>{movs.time}&nbsp; - &nbsp;{movs.type}</li>)}</ul>;
return <ModalBox title="Movements" data={table} />;
} else {
return Empty;
}
}
render() {
const movements = this.handleTable();
if (this.state.error) {
return Error;
}
if (this.state.loggedIn) {
return movements;
} else {
return (<ModalBox title="Movements" data=<h2>You have to login first!</h2> />);
}
}
}
export default MovementsPage;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}

View File

@ -0,0 +1,18 @@
import 'bootstrap/dist/css/bootstrap.css';
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
const targetUrl = "/api";
// // Dev address //
// const targetUrl = "http://localhost:3003/api";
ReactDOM.render(<App targetUrl={targetUrl}/>, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,135 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}

10291
bot.z_web/yarn.lock 100644

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,6 @@
"""Top-level package for Bot_Z."""
__author__ = """Leonardo Barcaroli"""
__email__ = "leonardo.barcaroli@deustechnology.com"
__version__ = "0.1.0"
__author__ = """Blallo"""
__email__ = "blallo@autistici.org"
__version__ = "1.1.3"

View File

@ -0,0 +1,102 @@
# -*- encoding: utf-8 -*-
"""
AsyncOperator is an async wrapper around the Operator sync operations.
It puts in the loop background the execution of the same methods in
the Operator.
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor, Executor
import functools
import logging
import typing as T
from bot_z.operator import Operator
from bot_z.exceptions import OperationFailed
alog = logging.getLogger("asyncio")
async def push_to_loop(
loop: asyncio.AbstractEventLoop,
executor: Executor,
func: T.Callable,
*args,
**kwargs
) -> T.Any:
sync_task = [
loop.run_in_executor(executor, functools.partial(func, **kwargs), *args)
]
res_set, _ = await asyncio.wait(sync_task, loop=loop)
res = res_set.pop()
return res.result()
# TODO: make it JSON-serializable
class AsyncOperator(object):
"""
This is the async version of the Operator.
This class DOES NOT inherit from Operator: it contains an
active instance of it.
"""
def __init__(self, base_uri: str, name: str, *args, **kwargs) -> None:
self.name = name
self.base_uri = base_uri
self.op = Operator(base_uri, name, *args, **kwargs)
self.executor = ThreadPoolExecutor(max_workers=2)
self.loop = asyncio.get_event_loop()
async def login(self, username: str, password: str) -> None:
"""Perform the login. Raise if failing."""
alog.debug("Logging in [%s]", self.name)
_ = await push_to_loop(
self.loop, self.executor, self.op.login, username, password
)
if not self.op.logged_in:
raise OperationFailed("Failed to login.")
alog.info("Logged in [%s]", self.name)
async def logout(self) -> None:
"""Perform the logout. Raise if failing."""
alog.debug("Logging out [%s]", self.name)
_ = await push_to_loop(self.loop, self.executor, self.op.logout)
if self.op.logged_in:
raise OperationFailed("Failed to logout.")
alog.info("Logged out [%s]", self.name)
async def checkin(self) -> None:
"""Perform the checkin. Raise if failing."""
alog.debug("Checking in [%s]", self.name)
_ = await push_to_loop(self.loop, self.executor, self.op.check_in)
if not self.op.checked_in:
raise OperationFailed("Failed to checkin.")
alog.info("Checked in [%s]", self.name)
async def checkout(self) -> None:
"""Perform the checkout. Raise if failing."""
alog.debug("Checking out [%s]", self.name)
_ = await push_to_loop(self.loop, self.executor, self.op.check_out)
if self.op.checked_in:
raise OperationFailed("Failed to checkout.")
alog.info("Checked out [%s]", self.name)
async def get_movements(self) -> T.List[T.Optional[T.Tuple[T.Text, T.Text]]]:
"""
Retrieves the list of movements as a list of tuples.
The list may be empty.
"""
alog.debug("Retrieving the list of movements [%s]", self.name)
res = await push_to_loop(self.loop, self.executor, self.op.get_movements)
alog.info("List of movements [%s]: %s", self.name, res)
return res
@property
def logged_in(self) -> bool:
return self.op.logged_in
@property
def checked_in(self) -> bool:
return self.op.checked_in

View File

@ -0,0 +1,2 @@
class OperationFailed(RuntimeError):
pass

View File

@ -8,6 +8,7 @@ service. It exposes methods to login, logout and check in and out.
from datetime import datetime, timedelta
import logging
import os
import platform
import pkg_resources
import shutil
import sys
@ -15,11 +16,40 @@ import time
import typing as T
from urllib.parse import urlparse
geckoexe = shutil.which("geckodriver")
def path_from_platform() -> T.Text:
res = None
_plat = sys.platform
_arch = platform.architecture()
if _plat == "darwin":
return "macos"
if _plat == "win32":
res = "win"
elif _plat.startswith("linux"):
res = "linux"
if res is None:
raise RuntimeError("Platform unknown: {}".format(_plat))
if "64" in _arch[0]:
res += "64"
elif "32" in _arch[0]:
res += "32"
return res
geckoname = "geckodriver"
geckoexe = shutil.which(geckoname)
if geckoexe is None:
local_path = pkg_resources.resource_filename(__name__, "bin")
_plat_path = path_from_platform()
local_path = pkg_resources.resource_filename(
__name__, os.path.join("bin", _plat_path)
)
try:
os.stat(os.path.join(local_path, "geckodriver"))
if "win" in _plat_path:
geckoname = f"{geckoname}.exe"
os.stat(os.path.join(local_path, geckoname))
os.environ["PATH"] = os.environ["PATH"] + ":" + local_path
except FileNotFoundError:
print("Missing geckodriver executable in path", file=sys.stderr)
@ -52,7 +82,6 @@ def safely(retries: int = 0) -> T.Callable:
def _protection(self, *args, **kwargs):
r = ret
done = False
while r > 0:
try:
val = f(self, *args, **kwargs)
@ -64,7 +93,7 @@ def safely(retries: int = 0) -> T.Callable:
"Something went wrong: %s [tentative #%s]", e, ret - r
)
r -= 1
time.sleep(1)
time.sleep(2) # TODO: set value from config.
return _protection
@ -111,7 +140,7 @@ class Operator(wd.Firefox):
headless: bool = True,
debug: bool = False,
*args,
**kwargs
**kwargs,
) -> None:
"""
Adds some configuration to Firefox.
@ -122,6 +151,7 @@ class Operator(wd.Firefox):
self.profile.set_preference("datareporting.policy.dataSubmissionEnabled", False)
self.profile.set_preference("datareporting.healthreport.service.enabled", False)
self.profile.set_preference("datareporting.healthreport.uploadEnabled", False)
self.profile.set_preference("dom.webnotifications.enabled", False)
self.opts = wd.firefox.options.Options()
self.opts.headless = headless
@ -149,10 +179,13 @@ class Operator(wd.Firefox):
self.logger.setLevel(logging.DEBUG)
self.logger.debug("Debug level")
self._logged_in = False
self._checked_in = False
self.username = None
# Clean preceding session
self.delete_all_cookies()
# Fix incompatibility
if not hasattr(self, "switch_to_default_content"):
self.logger.debug("switch_to_default_content patched.")
self.switch_to_default_content = self.switch_to.default_content
@safely(RETRIES)
def login(self, user: str, password: str, force: bool = False) -> None:
@ -199,11 +232,17 @@ class Operator(wd.Firefox):
login_butt.submit()
time.sleep(5)
self.logger.debug("Login result: %s", self.title)
safe_counter = 0
while "Routine window" in self.title:
self.logger.debug("Reloading...")
self.refresh()
self.logger.info("Reloaded %s", self.name)
self.switch_to.alert.accept()
time.sleep(0.5)
if safe_counter > 5:
self.logger.error("Too many reloads. Aborting.")
raise RuntimeError("Too many reloads.")
safe_counter += 1
if is_present(self, '//a[contains(@class, "imgMenu_ctrl")]', self.timeout):
self._logged_in = True
self.logger.info("Login succeeded for user: %s", user)
@ -269,12 +308,46 @@ class Operator(wd.Firefox):
_cookies = all(c in cookies for c in ("spcookie", "JSESSIONID", "ipclientid"))
return _cookies
def _switch_to_container(self) -> None:
try:
iframe = self.find_element_by_xpath(
'//iframe[contains(@id, "gsmd_container.jsp")]'
)
self.switch_to.frame(iframe)
except NoSuchElementException:
pass
def get_movements(self) -> T.List[T.Optional[T.Tuple[T.Text, T.Text]]]:
self._switch_to_container()
try:
result = [] # type: T.List[T.Tuple[T.Text]]
movements_table = self.find_elements_by_xpath(
'//div[contains(@class, "ushp_wterminale_container")]//div[@ps-name="Grid2"]//table/tbody/tr'
)
for row in movements_table:
data = row.text.strip().split("\n") # type: T.List[T.Text]
result.append( # type: ignore
tuple(i.strip() for i in data if i.strip())
)
except NoSuchElementException:
result = []
finally:
self.switch_to_default_content()
return result # type: ignore
@property
def checked_in(self) -> bool:
"""
Check if the user is checked in already.
"""
return self._checked_in
if not self.logged_in:
return False
dates = self.get_movements()
if not dates:
return False
if dates[-1][0] == "Entrata":
return True
return False
@safely(RETRIES)
def check_in(self, force: bool = False) -> None:
@ -284,19 +357,14 @@ class Operator(wd.Firefox):
if not force and not self.logged_in:
self.logger.warning("Not logged in!")
return
if self._checked_in:
if self.checked_in:
self.logger.warn("Already checked in!")
if not force:
return
iframe = self.find_element_by_xpath(
'//iframe[contains(@id, "gsmd_container.jsp")]'
)
self.switch_to.frame(iframe)
self._switch_to_container()
enter_butt = self.find_element_by_xpath('//input[@value="Entrata"]')
enter_butt.click()
# Click the check in button and change
# self._checked_in state in case of success
self._checked_in = True
self.switch_to_default_content()
@safely(RETRIES)
def check_out(self, force: bool = False) -> None:
@ -306,19 +374,14 @@ class Operator(wd.Firefox):
if not force and not self.logged_in:
self.logger.warning("Not logged in!")
return
if not self._checked_in:
if not self.checked_in:
self.logger.warn("Not yet checked in!")
if not force:
return
iframe = self.find_element_by_xpath(
'//iframe[contains(@id, "gsmd_container.jsp")]'
)
self.switch_to.frame(iframe)
self._switch_to_container()
exit_butt = self.find_element_by_xpath('//input[@value="Uscita"]')
exit_butt.click()
# Click the check in button and change
# self._checked_in state in case of success
self._checked_in = False
self.switch_to_default_content()
def __del__(self) -> None:
self.quit()

View File

@ -15,7 +15,7 @@ import threading
import time
import typing as T
from bot_z.bot_z import Operator
from bot_z.operator import Operator
from bot_z.utils import Fifo, PLifo, cmd_marshal, cmd_unmarshal
import daemon
from selenium.webdriver.common.keys import Keys

View File

@ -56,7 +56,6 @@ master_doc = "index"
# General information about the project.
project = u"Bot_Z"
copyright = u"2019, Leonardo Barcaroli"
# The version info for the project you're documenting, acts as replacement
# for |version| and |release|, also used in various other places throughout

34
entrypoint.sh 100755
View File

@ -0,0 +1,34 @@
#!/bin/bash
CONF="/tmp/botz.yaml"
if [ -z ${DEBUG} ]; then
_DEBUG="false"
else
_DEBUG="true"
fi
if [ -z ${BASE_URI} ]; then
echo "Missing BASE_URI environment variable."
exit 1
fi
cat > ${CONF} <<-EOF
base_uri: ${BASE_URI}
debug: ${_DEBUG}
log:
level: ${DEBUG_LEVEL:-INFO}
syslog: false
http:
bind_addr:
- "0.0.0.0"
port: 3003
EOF
echo "--------------------------------------------"
echo " STARTING WITH CONFIG:"
echo "--------------------------------------------"
cat ${CONF}
echo "--------------------------------------------"
z_app -c ${CONF}

View File

@ -1,15 +1,24 @@
[bumpversion]
current_version = 0.1.0
current_version = 1.1.3
commit = True
tag = True
sign_tags = True
[bumpversion:file:setup.py]
search = version='{current_version}'
replace = version='{new_version}'
search = VERSION = "{current_version}"
replace = VERSION = "{new_version}"
[bumpversion:file:bot_z/__init__.py]
search = __version__ = '{current_version}'
replace = __version__ = '{new_version}'
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
[bumpversion:file:Dockerfile]
search = LABEL io.troubles.botz.version="{current_version}"
replace = LABEL io.troubles.botz.version="{new_version}"
[bumpversion:file:Makefile]
search = VERSION = {current_version}
replace = VERSION = {new_version}
[bdist_wheel]
universal = 0
@ -18,4 +27,4 @@ universal = 0
exclude = docs
[aliases]
# Define setup.py command aliases here

134
setup.py
View File

@ -4,28 +4,40 @@
"""The setup script."""
from collections import namedtuple
from distutils.dir_util import copy_tree, mkpath
import glob
from html.parser import HTMLParser
import json
import os
import pkg_resources
from setuptools import setup, find_packages # noqa
from setuptools.command.develop import develop # noqa
from setuptools.command.install import install # noqa
from setuptools.command.bdist_egg import bdist_egg # noqa
from pprint import pformat
from setuptools import setup, find_packages # type: ignore
from setuptools.command.develop import develop # type: ignore
from setuptools.command.install import install # type: ignore
from setuptools.command.bdist_egg import bdist_egg # type: ignore
import shutil
import subprocess
import sys
import tarfile
import typing as T
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
from wheel.bdist_wheel import bdist_wheel # noqa
from wheel.bdist_wheel import bdist_wheel # type: ignore
import zipfile
GECKO_RELEASE_PATH = "https://github.com/mozilla/geckodriver"
PKG_NAME = "bot_z"
VERSION = "0.2.1"
VERSION = "1.1.3"
AUTHOR = "blallo"
AUTHOR_EMAIL = "blallo@autistici.org"
BIN_PATH = "bin/geckodriver"
ASSET_FILE_PATHS = [
"assets/*",
"assets/static/*",
"assets/static/css/*",
"assets/static/js/*",
]
with open("README.md") as readme_file:
readme = readme_file.read()
@ -35,6 +47,14 @@ requirements = [
"selenium>=3.141.0",
"lockfile>=0.12.2",
"python-daemon>=2.2.3",
"aiohttp>=3.5.4",
"aiodns>=2.0.0",
"cchardet",
"aiohttp-session==2.7.0",
"cryptography==2.7",
"PyYAML==5.1.2",
"passlib>=1.7.1, <2.0.0",
"bcrypt>=3.0.0",
]
setup_requirements = [] # type: T.List[str]
@ -42,6 +62,42 @@ setup_requirements = [] # type: T.List[str]
test_requirements = [] # type: T.List[str]
def _find_javascript_pkgmgr():
pkgmgr_list = ("yarn", "npm")
for _pkgmgr in pkgmgr_list:
if shutil.which(_pkgmgr):
pkgmgr = _pkgmgr
break
if not pkgmgr:
raise RuntimeError(
"Missing javascript package manager. Allowed are: {}".format(pkgmgr_list)
)
return pkgmgr
def _find_built_assets(base_path: str):
asset_manifest = os.path.join(base_path, "build", "asset-manifest.json")
with open(asset_manifest) as f:
manifest = json.load(f)
return manifest.get("files")
def build_web():
pkgmgr = _find_javascript_pkgmgr()
webpath = "./bot.z_web" # TODO: find base path
script = f"cd {webpath} && {pkgmgr} build"
print("[BUILD_WEB] running script:\n\t{}".format(script))
subprocess.check_call(["bash", "-c", script])
assets = _find_built_assets(webpath)
print("[BUILD_WEB] built assets: {}".format(pformat(assets)))
assets_path = pkg_resources.resource_filename("api", "assets")
if not os.path.exists(assets_path):
mkpath(assets_path)
copy_tree(os.path.join(webpath, "build"), assets_path)
class GitTags(HTMLParser):
tags: T.List[str] = list()
take_next = 0
@ -115,10 +171,26 @@ PLATFORM_MAP = {
"linux64": "linux64",
"darwin64": "macos",
"darwin32": "macos", # This is impossible (?)
"macos": "macos"
"macos": "macos",
}
def _identify_platform():
s_platform = sys.platform
if s_platform == "darwin":
return "macos"
is_64bits = sys.maxsize > 2 ** 32
if "linux" in s_platform:
plat = "linux"
elif "win" in s_platform:
plat = "win"
if is_64bits:
platform = "{}64".format(plat)
else:
platform = "{}32".format(plat)
return platform
def assemble_driver_uri(
version: T.Optional[str] = None, platform: T.Optional[str] = None
) -> str:
@ -128,13 +200,8 @@ def assemble_driver_uri(
# TODO: use pkg_resources.get_platform()
if not version:
version = find_latest_version(GECKO_RELEASE_PATH)
if not platform:
s_platform = sys.platform
is_64bits = sys.maxsize > 2 ** 32
if is_64bits:
platform = "{}64".format(s_platform)
else:
platform = "{}64".format(s_platform)
if platform is None:
platform = _identify_platform()
if "win" in platform:
ext = "zip"
else:
@ -167,18 +234,32 @@ def download_driver_bin(uri: str, path: str) -> None:
os.remove(filepath)
def preinstall(platform: T.Optional[str] = None) -> None:
def preinstall(platform: T.Text, build_iface: bool = True) -> None:
"""
Performs all the postintallation flow, donwloading in the
right place the geckodriver binary.
"""
# target_path = os.path.join(os.path.abspath(os.path.curdir), 'bot_z', 'bin')
target_path = pkg_resources.resource_filename("bot_z", "bin")
target_path = pkg_resources.resource_filename(
"bot_z", os.path.join("bin", platform)
)
if not os.path.exists(target_path):
mkpath(target_path)
ensure_local_folder()
version = os.environ.get("BOTZ_GECKO_VERSION")
gecko_uri = assemble_driver_uri(version, platform)
print("[POSTINSTALL] gecko_uri: {}".format(gecko_uri))
download_driver_bin(gecko_uri, target_path)
if build_iface:
build_web()
PLATS = {
"win32": "win32",
"win-amd64": "win64",
"manylinux1-i686": "linux32",
"manylinux1-x86_64": "linux64",
"macosx": "macos",
}
def translate_platform_to_gecko_vers(plat: str) -> str:
@ -189,16 +270,9 @@ def translate_platform_to_gecko_vers(plat: str) -> str:
if plat is None:
return None
PLATS = {
"win32": "win32",
"win-amd64": "win64",
"manylinux1-i686": "linux32",
"manylinux1-x86_64": "linux64",
"macosx": "macos",
}
try:
return PLATS[plat]
except KeyError as e:
except KeyError:
print("Allowed platforms are: {!r}".format(list(PLATS.keys())))
raise
@ -209,7 +283,8 @@ class CustomDevelopCommand(develop):
def run(self):
print("POSTINSTALL")
preinstall()
platform = _identify_platform()
preinstall(platform, build_iface=False)
super().run()
@ -252,15 +327,14 @@ setup(
author=AUTHOR,
author_email=AUTHOR_EMAIL,
url="https://git.abbiamoundominio.org/blallo/BotZ",
packages=find_packages(include=["bot_z"]),
packages=find_packages(include=["bot_z", "api"]),
cmdclass={
"develop": CustomDevelopCommand,
"install": CustomInstallCommand,
"bdist_wheel": CustomBDistWheel,
},
entry_points={"console_scripts": ["bot_z=bot_z.cli:cli"]},
package_data={"bot_z": ["bin/geckodriver"]},
include_package_data=True,
entry_points={"console_scripts": ["bot_z=bot_z.cli:cli", "z_app=api.app:cli"]},
package_data={"bot_z": ["bin/geckodriver"], "api": ASSET_FILE_PATHS},
install_requires=requirements,
license="GLWTS Public Licence",
zip_safe=False,