diff --git a/.do/deploy.template.yaml b/.do/deploy.template.yaml index b0d82435..4635a2f6 100644 --- a/.do/deploy.template.yaml +++ b/.do/deploy.template.yaml @@ -16,8 +16,5 @@ spec: - key: LMS_PSWD value: "admin123+" type: SECRET - - key: DATABASE_URL - scope: RUN_TIME - value: ${example-db.DATABASE_URL} databases: - name: example-db \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index ef796493..c28e2348 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,7 +7,6 @@ .idea .do - # Documentacion mkdocs.yml docs @@ -30,5 +29,5 @@ requirements-databases.txt .mypy.ini .gitpod.yml .gitpod.sh - -app.json \ No newline at end of file +app.json +sonar-project.properties diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1aa3167a..120927f6 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.7, 3.8, 3.9, "3.10"] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] steps: - uses: actions/checkout@v2 @@ -27,17 +27,26 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest + python -m pip install bandit flake8 pytest python -m pip install -r development.txt - name: Lint project run: | rm -rf build - flake8 now_lms + flake8 --ignore=E712 now_lms python -m build python -m twine check dist/* - pylint now_lms + python -m bandit -r now_lms + python -m flake8 now_lms + python -m pylint now_lms - name: Test with pytest run: | pytest -v --exitfirst --cov=now_lms + - name: Check python types + run: | + python -m pip install mypy + python -m mypy --install-types --non-interactive now_lms + - name: OCI Image + run: | + docker buildx build . - name: Codecov uses: codecov/codecov-action@v1 diff --git a/.gitpod.sh b/.gitpod.sh index 3485f7fc..6606657d 100644 --- a/.gitpod.sh +++ b/.gitpod.sh @@ -4,9 +4,11 @@ sudo apt install -y sqlite git config pull.rebase true python -m pip install --upgrade pip python -m pip install --upgrade pip -python -m pip install -r development.txt +python -m pip install --upgrade -r development.txt python -m pip install -e . python setup.py develop mypy now_lms --install-types --non-interactive python -m pip install hupper -/home/gitpod/.pyenv/versions/3.8.12/bin/hupper -m waitress --port=8080 now_lms:app \ No newline at end of file +python -m flask setup +/home/gitpod/.pyenv/versions/3.8.12/bin/hupper -m waitress --port=8080 now_lms:app +git push heroku mastergit remote add heroku https://git.heroku.com/app.git \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 02c5bcf6..3492987b 100644 --- a/.pylintrc +++ b/.pylintrc @@ -74,17 +74,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". -disable=print-statement, - parameter-unpacking, - unpacking-in-except, - old-raise-syntax, - backtick, - long-suffix, - old-ne-operator, - old-octal-literal, - import-star-module-level, - non-ascii-bytes-literal, - raw-checker-failed, +disable=raw-checker-failed, bad-inline-option, locally-disabled, file-ignored, @@ -92,67 +82,6 @@ disable=print-statement, useless-suppression, deprecated-pragma, use-symbolic-message-instead, - apply-builtin, - basestring-builtin, - buffer-builtin, - cmp-builtin, - coerce-builtin, - execfile-builtin, - file-builtin, - long-builtin, - raw_input-builtin, - reduce-builtin, - standarderror-builtin, - unicode-builtin, - xrange-builtin, - coerce-method, - delslice-method, - getslice-method, - setslice-method, - no-absolute-import, - old-division, - dict-iter-method, - dict-view-method, - next-method-called, - metaclass-assignment, - indexing-exception, - raising-string, - reload-builtin, - oct-method, - hex-method, - nonzero-method, - cmp-method, - input-builtin, - round-builtin, - intern-builtin, - unichr-builtin, - map-builtin-not-iterating, - zip-builtin-not-iterating, - range-builtin-not-iterating, - filter-builtin-not-iterating, - using-cmp-argument, - eq-without-hash, - div-method, - idiv-method, - rdiv-method, - exception-message-attribute, - invalid-str-codec, - sys-max-int, - bad-python3-import, - deprecated-string-function, - deprecated-str-translate-call, - deprecated-itertools-function, - deprecated-types-field, - next-method-defined, - dict-items-not-iterating, - dict-keys-not-iterating, - dict-values-not-iterating, - deprecated-operator-function, - deprecated-urllib-function, - xreadlines-attribute, - deprecated-sys-function, - exception-escape, - comprehension-escape, # Por la naturaleza del proyecto no todos los import estan al inicio para evitar # importaciones circulares import-outside-toplevel, diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz new file mode 100644 index 00000000..e06a4da5 Binary files /dev/null and b/.yarn/install-state.gz differ diff --git a/.yarnrc b/.yarnrc deleted file mode 100644 index 36566217..00000000 --- a/.yarnrc +++ /dev/null @@ -1,2 +0,0 @@ -# Directorio para instalar packeques de NodeJS ---modules-folder now_lms/static/node_modules/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 13b88f4f..bfca59e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,9 @@ -FROM registry.access.redhat.com/ubi8/ubi-minimal AS js -RUN rpm --import https://dl.yarnpkg.com/rpm/pubkey.gpg \ - && curl -sL https://dl.yarnpkg.com/rpm/yarn.repo -o /etc/yum.repos.d/yarn.repo \ - && microdnf -y install yarn -COPY package.json . -COPY yarn.lock . -RUN yarn +FROM registry.access.redhat.com/ubi9/ubi-minimal AS js +RUN microdnf install -y nodejs npm +COPY ./now_lms/static/package.json package.json +RUN npm install -FROM registry.access.redhat.com/ubi8/ubi-minimal +FROM registry.access.redhat.com/ubi9/ubi-minimal ENV LANG C.UTF-8 ENV LC_ALL C.UTF-8 @@ -14,7 +11,7 @@ ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED = 1 ENV FLASK_ENV "production" -RUN microdnf install -y --nodocs --best --refresh python39 python39-pip python39-cryptography \ +RUN microdnf install -y --nodocs --best --refresh python39 python3-pip python3-cryptography \ && microdnf clean all # Install dependencies in a layer diff --git a/README.md b/README.md index ac3221ae..9eb6a1d4 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,11 @@ ![PyPI - License](https://img.shields.io/pypi/l/now_lms?color=brightgreen&logo=apache&logoColor=white) ![PyPI](https://img.shields.io/pypi/v/now_lms?color=brightgreen&label=version&logo=python&logoColor=white) ![PyPI - Wheel](https://img.shields.io/pypi/wheel/now_lms?logo=python&logoColor=white) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![linting: pylint](https://img.shields.io/badge/linting-pylint-yellow)](https://github.com/PyCQA/pylint) [![codecov](https://codecov.io/gh/bmosoluciones/now-lms/branch/main/graph/badge.svg?token=SFVXF6Y3R3)](https://codecov.io/gh/bmosoluciones/now-lms) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=bmosoluciones_now-lms&metric=alert_status)](https://sonarcloud.io/dashboard?id=bmosoluciones_now-lms) +[![Join the chat at https://gitter.im/now-lms/community](https://badges.gitter.im/now-lms/community.svg)](https://gitter.im/now-lms/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) Simple to {install, use, configure, mantain} learning management system. @@ -30,32 +33,37 @@ python -m pip install now_lms python -m now_lms ``` -Visit http://127.0.0.1:8080/ in your browser, defaul user and password are `lms-admin`, note that the default server is olny bind to the localhost. +Visit http://127.0.0.1:8080/ in your browser, defaul user and password are `lms-admin`, note that the default server is only bind to the localhost. ### Other deployment options There are available templates to deploy Now - LMS to these services: [![Deploy to DO](https://img.shields.io/badge/DO-Deploy%20to%20DO-blue "Deploy as Digital Ocean App")](https://cloud.digitalocean.com/apps/new?repo=https://github.com/bmosoluciones/now-lms/tree/main) -[![Deploy to Heroku](https://img.shields.io/badge/Heroku-Deploy%20to%20Heroku-blueviolet "Deploy to Heroku")](https://heroku.com/deploy?template=https://github.com/bmosoluciones/now-lms/tree/main) +[![Deploy to Heroku](https://img.shields.io/badge/Heroku-Deploy%20to%20Heroku-blueviolet "Deploy to Heroku")](https://heroku.com/deploy?template=https://github.com/bmosoluciones/now-lms/tree/heroku) ### OCI Image -[![Docker Repository on Quay](https://quay.io/repository/bmosoluciones/now-lms/status "Docker Repository on Quay")](https://quay.io/repository/bmosoluciones/now-lms) [![Join the chat at https://gitter.im/now-lms/community](https://badges.gitter.im/now-lms/community.svg)](https://gitter.im/now-lms/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +[![Docker Repository on Quay](https://quay.io/repository/bmosoluciones/now_lms/status "Docker Repository on Quay")](https://quay.io/repository/bmosoluciones/now_lms) There is also a OCI image disponible if you prefer to user containers, in this example we use [podman](https://podman.io/): ``` +# <---------------------------------------------> # # Install the podman command line tool. -# Fedora, CentOS ... +# DNF family (CentOS, Rocky, Alma): sudo dnf -y install podman -# Debian, Ubuntu ... +# APT family (Debian, Ubuntu): sudo apt install -y podman -# OpenSUSE +# OpenSUSE: sudo zypper in podman + +# <---------------------------------------------> # +# Run the software. # Create a new pod: podman pod create --name now-lms -p 80:80 -p 443:443 @@ -77,4 +85,42 @@ podman run --pod now-lms --name now-lms-server --rm -v $PWD/nginx.conf:/etc/ngin ``` -NOW-LMS also will work with MySQL or MariaDB just change the image of the database container and set the correct connect string. +NOW-LMS also will work with MySQL or MariaDB just change the image of the database container and set the correct connect string. SQLite also will work if you will serve a few users. + +## Contributing + +### Getting the source code + +``` +git clone https://github.com/bmosoluciones/now-lms.git +``` +### Create a python virtual env + +``` +python3 -m venv venv +# Linux: +source venv/bin/activate +# Windows +venv\Scripts\activate.bat +``` +### Install python deps + +``` +python3 - m pip install -r development.txt +``` + +### Install Boostrap + +``` + cd now_lms/static/ + npm install +``` + +### Start a development server + +``` +hupper -m waitress --port=8080 now_lms:app +``` +Please note that we use waitress as WSGI server because gunicorn do not work on Windows, hupper will live reload the WSGI server as you save changes in the source code so you will be able to work with your changes as you work, please note that changes to the jinja templates will not trigger the server reload, only changes to python files. + +Default user and password are ```lms-admin```, default url to work with th server will be ```http://127.0.0.1:8080/```. diff --git a/app.json b/app.json index 29f1b832..4dbee2d5 100644 --- a/app.json +++ b/app.json @@ -6,7 +6,7 @@ "logo": "https://github.com/bmosoluciones/now-lms/raw/main/now_lms/static/icons/logo/logo_small.png", "keywords": ["lms", "python"], "scripts": { - "postdeploy": "python -m flask setup" + "postdeploy": "python -m pip install psycopg2-binary && python -m flask setup" }, "env": { "LMS_KEY": { diff --git a/development.txt b/development.txt index 8a5ccda4..e8e0ad20 100644 --- a/development.txt +++ b/development.txt @@ -1,6 +1,9 @@ # Base -r requirements.txt +# Servidor de desarrollo +hupper + # Crear tarball y wheel build twine @@ -25,3 +28,7 @@ mkdocs-material # Verificación de tipos mypy + +#Live realoader +hupper +# hupper -m now_lms \ No newline at end of file diff --git a/now_lms/__init__.py b/now_lms/__init__.py index 9d6adf2b..950adbdb 100644 --- a/now_lms/__init__.py +++ b/now_lms/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2021 BMO Soluciones, S.A. +# Copyright 2022 BMO Soluciones, S.A. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,257 +17,80 @@ """NOW Learning Management System.""" + +# pylint: disable=E1101 + # Libreria standar: import sys from functools import wraps -from os import environ, name, path, cpu_count -from pathlib import Path -from typing import Dict, Union +from os import environ, cpu_count +from uuid import uuid4 # Librerias de terceros: from flask import Flask, abort, flash, redirect, request, render_template, url_for, current_app from flask.cli import FlaskGroup from flask_alembic import Alembic -from flask_login import LoginManager, UserMixin, current_user, login_required, login_user, logout_user -from flask_sqlalchemy import SQLAlchemy -from flask_wtf import FlaskForm -from flask_uploads import IMAGES, UploadSet, configure_uploads +from flask_login import LoginManager, current_user, login_required, login_user, logout_user +from flask_uploads import configure_uploads from loguru import logger as log from pg8000.dbapi import ProgrammingError as PGProgrammingError from pg8000.exceptions import DatabaseError from sqlalchemy.exc import ArgumentError, OperationalError, ProgrammingError -from wtforms import BooleanField, DecimalField, DateField, IntegerField, PasswordField, SelectField, StringField, SubmitField -from wtforms.validators import DataRequired + # Recursos locales: -from now_lms.version import PRERELEASE, VERSION +from now_lms.auth import validar_acceso, proteger_passwd +from now_lms.config import DIRECTORIO_PLANTILLAS, DIRECTORIO_ARCHIVOS, DESARROLLO, CONFIGURACION, CARGA_IMAGENES +from now_lms.db import ( + database, + Configuracion, + Curso, + CursoRecurso, + CursoSeccion, + DocenteCurso, + EstudianteCurso, + ModeradorCurso, + Usuario, + crear_cursos_predeterminados, + crear_usuarios_predeterminados, + verifica_docente_asignado_a_curso, + verifica_estudiante_asignado_a_curso, + verifica_moderador_asignado_a_curso, + MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, +) +from now_lms.bi import ( + asignar_curso_a_instructor, + modificar_indice_curso, + modificar_indice_seccion, + cambia_estado_curso_por_id, + reorganiza_indice_curso, + reorganiza_indice_seccion, + cambia_tipo_de_usuario_por_id, + cambia_curso_publico, + cambia_seccion_publico, +) +from now_lms.forms import LoginForm, LogonForm, CurseForm, CursoRecursoVideoYoutube, CursoSeccionForm +from now_lms.version import VERSION # < --------------------------------------------------------------------------------------------- > # Metadatos __version__: str = VERSION -DESARROLLO: bool = ( - (PRERELEASE is not None) or ("FLASK_DEBUG" in environ) or (environ.get("FLASK_ENV") == "development") or ("CI" in environ) -) APPNAME: str = "NOW LMS" if DESARROLLO: - log.warning("Opciones de desarrollo detectadas, favor revise su configuración.") + log.warning("Se detecto que tiene configuradas las opciones de desarrollo.") + log.warning("Con las opciones de desarrollo habilitadas puede experimentar perdida de datos.") + log.warning("Revise su configuración si desea que sus cambios sean permanentes.") # < --------------------------------------------------------------------------------------------- > # Datos predefinidos TIPOS_DE_USUARIO: list = ["admin", "user", "instructor", "moderator"] -# < --------------------------------------------------------------------------------------------- > -# Directorios de la aplicacion -DIRECTORIO_APP: str = path.abspath(path.dirname(__file__)) -DIRECTORIO_PRINCICIPAL: Path = Path(DIRECTORIO_APP).parent.absolute() -DIRECTORIO_PLANTILLAS: str = path.join(DIRECTORIO_APP, "templates") -DIRECTORIO_ARCHIVOS: str = path.join(DIRECTORIO_APP, "static") -DIRECTORIO_BASE_ARCHIVOS_DE_USUARIO: str = path.join(DIRECTORIO_APP, "static", "files") -DIRECTORIO_ARCHIVOS_PUBLICOS: str = path.join(DIRECTORIO_BASE_ARCHIVOS_DE_USUARIO, "public") -DIRECTORIO_ARCHIVOS_PRIVADOS: str = path.join(DIRECTORIO_BASE_ARCHIVOS_DE_USUARIO, "private") - - -# < --------------------------------------------------------------------------------------------- > -# Directorios utilizados para la carga de archivos. -DIRECTORIO_IMAGENES: str = path.join(DIRECTORIO_ARCHIVOS_PUBLICOS, "img") -CARGA_IMAGENES = UploadSet("photos", IMAGES) - -# < --------------------------------------------------------------------------------------------- > -# Ubicación predeterminada de base de datos SQLITE -if name == "nt": - SQLITE: str = "sqlite:///" + str(DIRECTORIO_PRINCICIPAL) + "\\now_lms.db" -else: - SQLITE = "sqlite:///" + str(DIRECTORIO_PRINCICIPAL) + "/now_lms.db" - - -# < --------------------------------------------------------------------------------------------- > -# Configuración de la aplicación, siguiendo "Twelve Factors App" las opciones se leen del entorno -# o se utilizan valores predeterminados. -CONFIGURACION: Dict = { - "ADMIN_USER": environ.get("LMS_USER") or "lms-admin", - "ADMIN_PSWD": environ.get("LMS_PSWD") or "lms-admin", - "SECRET_KEY": environ.get("LMS_KEY") or "dev", - "SQLALCHEMY_DATABASE_URI": environ.get("LMS_DB") or environ.get("DATABASE_URL") or SQLITE, - "SQLALCHEMY_TRACK_MODIFICATIONS": "False", - # Carga de archivos - "UPLOADED_PHOTOS_DEST": DIRECTORIO_IMAGENES, -} - -# Servicios como Heroku, Elephantsql, Digital Ocean proveen una direccion de corrección que comienza con "postgres" -# esta va a fallar con SQLAlchemy. -if "postgres:" in CONFIGURACION.get("SQLALCHEMY_DATABASE_URI"): - CONFIGURACION["SQLALCHEMY_DATABASE_URI"] = "postgresql+pg8000" + CONFIGURACION.get("SQLALCHEMY_DATABASE_URI")[8:] - - # < --------------------------------------------------------------------------------------------- > # Inicialización de extensiones de terceros alembic: Alembic = Alembic() administrador_sesion: LoginManager = LoginManager() -database: SQLAlchemy = SQLAlchemy() - - -# < --------------------------------------------------------------------------------------------- > -# Base de datos relacional - -MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA: int = 3 -# Para hacer feliz a Sonar Cloud -# https://sonarcloud.io/project/overview?id=bmosoluciones_now-lms -LLAVE_FORONEA_CURSO: str = "curso.codigo" -LLAVE_FORONEA_USUARIO: str = "usuario.usuario" - - -# pylint: disable=too-few-public-methods -# pylint: disable=no-member -class BaseTabla: - """Columnas estandar para todas las tablas de la base de datos.""" - - # Pistas de auditoria comunes a todas las tablas. - id = database.Column(database.Integer(), primary_key=True, nullable=True) - status = database.Column(database.String(50), nullable=True) - creado = database.Column(database.DateTime, default=database.func.now(), nullable=False) - creado_por = database.Column(database.String(15), nullable=True) - modificado = database.Column(database.DateTime, default=database.func.now(), onupdate=database.func.now(), nullable=True) - modificado_por = database.Column(database.String(15), nullable=True) - - -class Usuario(UserMixin, database.Model, BaseTabla): # type: ignore[name-defined] - """Una entidad con acceso al sistema.""" - - # Información Básica - __table_args__ = (database.UniqueConstraint("usuario", name="usuario_unico"),) - usuario = database.Column(database.String(150), nullable=False) - acceso = database.Column(database.LargeBinary(), nullable=False) - nombre = database.Column(database.String(100)) - apellido = database.Column(database.String(100)) - correo_electronico = database.Column(database.String(100)) - # Tipo puede ser: admin, user, instructor, moderator - tipo = database.Column(database.String(20)) - activo = database.Column(database.Boolean()) - genero = database.Column(database.String(1)) - nacimiento = database.Column(database.Date()) - - -class Curso(database.Model, BaseTabla): # type: ignore[name-defined] - """Un curso es la base del aprendizaje en NOW LMS.""" - - __table_args__ = (database.UniqueConstraint("codigo", name="curso_codigo_unico"),) - nombre = database.Column(database.String(150), nullable=False) - codigo = database.Column(database.String(20), unique=True) - descripcion = database.Column(database.String(500), nullable=False) - # draft, public, active, closed - estado = database.Column(database.String(10), nullable=False) - # mooc - publico = database.Column(database.Boolean()) - certificado = database.Column(database.Boolean()) - auditable = database.Column(database.Boolean()) - precio = database.Column(database.Numeric()) - capacidad = database.Column(database.Integer()) - fecha_inicio = database.Column(database.Date()) - fecha_fin = database.Column(database.Date()) - duracion = database.Column(database.Integer()) - portada = database.Column(database.String(250), nullable=True, default=None) - nivel = database.Column(database.Integer()) - - -class CursoSeccion(database.Model, BaseTabla): # type: ignore[name-defined] - """Los cursos tienen secciones para dividir el contenido en secciones logicas.""" - - curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) - nombre = database.Column(database.String(150), nullable=False) - indice = database.Column(database.Integer()) - - -class CursoRecurso(database.Model, BaseTabla): # type: ignore[name-defined] - """Un curso consta de una serie de recursos.""" - - curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) - seccion = database.Column(database.Integer(), database.ForeignKey("curso_seccion.id"), nullable=False) - nombre = database.Column(database.String(150), nullable=False) - indice = database.Column(database.Integer()) - - -class Files(database.Model, BaseTabla): # type: ignore[name-defined] - """Listado de archivos que se han cargado a la aplicacion.""" - - archivo = database.Column(database.String(100), nullable=False) - tipo = database.Column(database.String(15), nullable=False) - hash = database.Column(database.String(50), nullable=False) - url = database.Column(database.String(100), nullable=False) - - -class DocenteCurso(database.Model, BaseTabla): # type: ignore[name-defined] - """Uno o mas usuario de tipo intructor pueden estar a cargo de un curso.""" - - curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) - usuario = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_USUARIO), nullable=False) - vigente = database.Column(database.Boolean()) - - -class ModeradorCurso(database.Model, BaseTabla): # type: ignore[name-defined] - """Uno o mas usuario de tipo moderator pueden estar a cargo de un curso.""" - - curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) - usuario = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_USUARIO), nullable=False) - vigente = database.Column(database.Boolean()) - - -class EstudianteCurso(database.Model, BaseTabla): # type: ignore[name-defined] - """Uno o mas usuario de tipo user pueden estar a cargo de un curso.""" - - curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) - usuario = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_USUARIO), nullable=False) - vigente = database.Column(database.Boolean()) - - -class Configuracion(database.Model, BaseTabla): # type: ignore[name-defined] - """ - Repositorio Central para la configuración de la aplicacion. - - Realmente esta tabla solo va a contener un registro con una columna para cada opción, en las plantillas - va a estar disponible como la variable global config. - """ - - titulo = database.Column(database.String(150), nullable=False) - descripcion = database.Column(database.String(500), nullable=False) - # Uno de mooc, school, training - modo = database.Column(database.String(500), nullable=False, default="mooc") - # Pagos en linea - paypal_key = database.Column(database.String(150), nullable=True) - stripe_key = database.Column(database.String(150), nullable=True) - # Micelaneos - dev_docs = database.Column(database.Boolean(), default=False) - # Permitir al usuario cargar archivos - file_uploads = database.Column(database.Boolean(), default=False) - - -# < --------------------------------------------------------------------------------------------- > -# Funciones auxiliares relacionadas a contultas de la base de datos. - - -def verifica_docente_asignado_a_curso(id_curso: Union[None, str] = None): - """Si el usuario no esta asignado como docente al curso devuelve None.""" - if current_user.is_authenticated: - return DocenteCurso.query.filter(DocenteCurso.usuario == current_user.usuario, DocenteCurso.curso == id_curso) - else: - return False - - -def verifica_moderador_asignado_a_curso(id_curso: Union[None, str] = None): - """Si el usuario no esta asignado como moderador al curso devuelve None.""" - if current_user.is_authenticated: - return ModeradorCurso.query.filter(ModeradorCurso.usuario == current_user.usuario, ModeradorCurso.curso == id_curso) - else: - return False - - -def verifica_estudiante_asignado_a_curso(id_curso: Union[None, str] = None): - """Si el usuario no esta asignado como estudiante al curso devuelve None.""" - if current_user.is_authenticated: - return EstudianteCurso.query.filter(EstudianteCurso.usuario == current_user.usuario, EstudianteCurso.curso == id_curso) - else: - return False # < --------------------------------------------------------------------------------------------- > @@ -283,68 +106,12 @@ def cargar_sesion(identidad): @administrador_sesion.unauthorized_handler -def no_autorizado(): +def no_autorizado(): # pragma: no cover """Redirecciona al inicio de sesión usuarios no autorizados.""" flash("Favor iniciar sesión para acceder al sistema.") return INICIO_SESION -def proteger_passwd(clave): - """Devuelve una contraseña salteada con bcrytp.""" - from bcrypt import hashpw, gensalt - - return hashpw(clave.encode(), gensalt()) - - -def validar_acceso(usuario_id, acceso): - """Verifica el inicio de sesión del usuario.""" - from bcrypt import checkpw - - registro = Usuario.query.filter_by(usuario=usuario_id).first() - if registro is not None: - clave_validada = checkpw(acceso.encode(), registro.acceso) - else: - clave_validada = False - return clave_validada - - -# < --------------------------------------------------------------------------------------------- > -# Definición de formularios -class LoginForm(FlaskForm): - """Formulario de inicio de sesión.""" - - usuario = StringField(validators=[DataRequired()]) - acceso = PasswordField(validators=[DataRequired()]) - inicio_sesion = SubmitField() - - -class LogonForm(FlaskForm): - """Formulario para crear un nuevo usuario.""" - - usuario = StringField(validators=[DataRequired()]) - acceso = PasswordField(validators=[DataRequired()]) - nombre = StringField(validators=[DataRequired()]) - apellido = StringField(validators=[DataRequired()]) - correo_electronico = StringField(validators=[DataRequired()]) - - -class CurseForm(FlaskForm): - """Formulario para crear un nuevo curso.""" - - nombre = StringField(validators=[DataRequired()]) - codigo = StringField(validators=[DataRequired()]) - descripcion = StringField(validators=[DataRequired()]) - publico = BooleanField(validators=[]) - auditable = BooleanField(validators=[]) - certificado = BooleanField(validators=[]) - precio = DecimalField(validators=[]) - capacidad = IntegerField(validators=[]) - fecha_inicio = DateField(validators=[]) - fecha_fin = DateField(validators=[]) - duracion = IntegerField(validators=[]) - nivel = SelectField("User", choices=[(0, "Introductorio"), (1, "Principiante"), (2, "Intermedio"), (2, "Avanzado")]) - - # < --------------------------------------------------------------------------------------------- > # Definición de la aplicación lms_app = Flask( @@ -355,7 +122,9 @@ class CurseForm(FlaskForm): lms_app.config.from_mapping(CONFIGURACION) -with lms_app.app_context(): +# Inicializamos extenciones y cargamos algunas variables para que esten disponibles de forma +# global en las plantillas de Jinja2. +with lms_app.app_context(): # pragma: no cover alembic.init_app(lms_app) administrador_sesion.init_app(lms_app) database.init_app(lms_app) @@ -371,9 +140,11 @@ class CurseForm(FlaskForm): except DatabaseError: CONFIG = None if CONFIG: - log.info("Configuración detectada.") + log.info("Configuración cargada correctamente.") else: - log.warning("No se pudo cargar la configuración.") + log.warning("No se detecto configuración de usuario.") + log.warning("Utilizando configuración predeterminada.") + # Asignamos variables globales para ser utilizadas dentro de las plantillas del sistema. lms_app.jinja_env.globals["current_user"] = current_user lms_app.jinja_env.globals["config"] = CONFIG lms_app.jinja_env.globals["docente_asignado"] = verifica_docente_asignado_a_curso @@ -392,21 +163,14 @@ def init_app(): log.info("Iniciando Configuracion de la aplicacion.") log.info("Creando esquema de base de datos.") database.create_all() - log.info("Creando usuario administrador.") - administrador = Usuario( - usuario=CONFIGURACION.get("ADMIN_USER"), - acceso=proteger_passwd(CONFIGURACION.get("ADMIN_PSWD")), - tipo="admin", - activo=True, - ) config = Configuracion( titulo="NOW LMS", descripcion="Sistema de aprendizaje en linea.", ) - database.session.add(administrador) database.session.add(config) database.session.commit() - + crear_usuarios_predeterminados() + crear_cursos_predeterminados() else: log.warning("NOW LMS ya se encuentra configurado.") log.warning("Intente ejecutar 'python -m now_lms'") @@ -445,16 +209,16 @@ def serve(): # pragma: no cover @lms_app.errorhandler(404) -def error_404(error): +def error_404(error): # pragma: no cover """Pagina personalizada para recursos no encontrados.""" - assert error is not None + assert error is not None # nosec B101 return render_template("404.html"), 404 @lms_app.errorhandler(403) -def error_403(error): +def error_403(error): # pragma: no cover """Pagina personalizada para recursos no autorizados.""" - assert error is not None + assert error is not None # nosec B101 return render_template("403.html"), 403 @@ -467,73 +231,14 @@ def error_403(error): ) -def command(as_module=False) -> None: +def command(as_module=False) -> None: # pragma: no cover """Linea de comandos para administración de la aplicacion.""" COMMAND_LINE_INTERFACE.main(args=sys.argv[1:], prog_name="python -m flask" if as_module else None) -# < --------------------------------------------------------------------------------------------- > -# Funciones auxiliares -def asignar_curso_a_instructor(curso_codigo: Union[None, str] = None, usuario_id: Union[None, str] = None): - """Asigna un usuario como instructor de un curso.""" - ASIGNACION = DocenteCurso(curso=curso_codigo, usuario=usuario_id, vigente=True, creado_por=current_user.usuario) - database.session.add(ASIGNACION) - database.session.commit() - - -def asignar_curso_a_moderador(curso_codigo: Union[None, str] = None, usuario_id: Union[None, str] = None): - """Asigna un usuario como moderador de un curso.""" - ASIGNACION = ModeradorCurso(usuario=usuario_id, curso=curso_codigo, vigente=True, creado_por=current_user.usuario) - database.session.add(ASIGNACION) - database.session.commit() - - -def asignar_curso_a_estudiante(curso_codigo: Union[None, str] = None, usuario_id: Union[None, str] = None): - """Asigna un usuario como moderador de un curso.""" - ASIGNACION = EstudianteCurso( - creado_por=current_user.usuario, - curso=curso_codigo, - usuario=usuario_id, - vigente=True, - ) - database.session.add(ASIGNACION) - database.session.commit() - - -def cambia_tipo_de_usuario_por_id(id_usuario: Union[None, str] = None, nuevo_tipo: Union[None, str] = None): - """ - Cambia el estatus de un usuario del sistema. - - Los valores reconocidos por el sistema son: admin, user, instructor, moderator. - """ - USUARIO = Usuario.query.filter_by(usuario=id_usuario).first() - USUARIO.tipo = nuevo_tipo - database.session.commit() - - -def cambia_estado_curso_por_id(id_curso: Union[None, str, int] = None, nuevo_estado: Union[None, str] = None): - """ - Cambia el estatus de un curso. - - Los valores reconocidos por el sistema son: draft, public, open, closed. - """ - CURSO = Curso.query.filter_by(codigo=id_curso).first() - CURSO.estado = nuevo_estado - database.session.commit() - - -def cambia_curso_publico(id_curso: Union[None, str, int] = None): - """Cambia el estatus publico de un curso.""" - CURSO = Curso.query.filter_by(codigo=id_curso).first() - if CURSO.publico: - CURSO.publico = False - else: - CURSO.publico = True - database.session.commit() - - # < --------------------------------------------------------------------------------------------- > # Definición de rutas/vistas +# pylint: disable=singleton-comparison def perfil_requerido(perfil_id): @@ -555,10 +260,6 @@ def wrapper(*args, **kwargs): return decorator_verifica_acceso -# < --------------------------------------------------------------------------------------------- > -# Definición de rutas/vistas -# pylint: disable=singleton-comparison - # <-------- Autenticación de usuarios --------> INICIO_SESION = redirect("/login") @@ -652,9 +353,14 @@ def cerrar_sesion(): @lms_app.route("/index") def home(): """Página principal de la aplicación.""" - CURSOS = Curso.query.filter(Curso.publico == True, Curso.estado == "public").paginate( # noqa: E712 - request.args.get("page", default=1, type=int), 6, False + + CURSOS = database.paginate( + database.select(Curso).filter(Curso.publico == True, Curso.estado == "public"), # noqa: E712 + page=request.args.get("page", default=1, type=int), + max_per_page=MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, + count=True, ) + return render_template("inicio/mooc.html", cursos=CURSOS) @@ -741,35 +447,176 @@ def nuevo_curso(): return render_template("learning/nuevo_curso.html", form=form) +@lms_app.route("/course//new_seccion", methods=["GET", "POST"]) +@login_required +@perfil_requerido("instructor") +def nuevo_seccion(course_code): + """Formulario para crear un nuevo recurso.""" + form = CursoSeccionForm() + if form.validate_on_submit() or request.method == "POST": + ramdon = uuid4() + id_unico = str(ramdon.hex) + secciones = CursoSeccion.query.filter_by(curso=course_code).count() + nuevo_indice = int(secciones + 1) + nueva_seccion = CursoSeccion( + codigo=id_unico, + curso=course_code, + nombre=form.nombre.data, + descripcion=form.descripcion.data, + estado=False, + indice=nuevo_indice, + ) + try: + database.session.add(nueva_seccion) + database.session.commit() + flash("Sección agregada correctamente al curso.") + if secciones > 4: + reorganiza_indice_curso(codigo_curso=course_code) + return redirect(url_for("curso", course_code=course_code)) + except OperationalError: + flash("Hubo en error al crear la seccion.") + return redirect(url_for("curso", course_code=course_code)) + else: + return render_template("learning/nuevo_seccion.html", form=form) + + +@lms_app.route("/course//seccion/increment/") +@login_required +@perfil_requerido("instructor") +def incrementar_indice_seccion(course_code, indice): + """Actualiza indice de secciones.""" + modificar_indice_curso( + codigo_curso=course_code, + indice=int(indice), + task="decrement", + ) + return redirect(url_for("curso", course_code=course_code)) + + +@lms_app.route("/course//seccion/decrement/") +@login_required +@perfil_requerido("instructor") +def reducir_indice_seccion(course_code, indice): + """Actualiza indice de secciones.""" + modificar_indice_curso( + codigo_curso=course_code, + indice=int(indice), + task="increment", + ) + return redirect(url_for("curso", course_code=course_code)) + + +@lms_app.route("/course///new_resource") +@login_required +@perfil_requerido("instructor") +def nuevo_recurso(course_code, seccion): + """Página para seleccionar tipo de recurso.""" + return render_template("learning/nuevo_recurso.html", id_curso=course_code, id_seccion=seccion) + + +@lms_app.route("/course///youtube/new", methods=["GET", "POST"]) +@login_required +@perfil_requerido("instructor") +def nuevo_recurso_youtube_video(course_code, seccion): + """Formulario para crear un nuevo recurso tipo vídeo en Youtube.""" + form = CursoRecursoVideoYoutube() + recursos = CursoRecurso.query.filter_by(seccion=seccion).count() + nuevo_indice = int(recursos + 1) + if form.validate_on_submit() or request.method == "POST": + ramdon = uuid4() + id_unico = str(ramdon.hex) + nuevo_recurso_ = CursoRecurso( + codigo=id_unico, + curso=course_code, + seccion=seccion, + tipo="youtube", + nombre=form.nombre.data, + descripcion=form.descripcion.data, + youtube_url=form.youtube_url.data, + indice=nuevo_indice, + ) + try: + database.session.add(nuevo_recurso_) + database.session.commit() + flash("Recurso agregado correctamente al curso.") + return redirect(url_for("curso", course_code=course_code)) + except OperationalError: + flash("Hubo en error al crear el recurso.") + return redirect(url_for("curso", course_code=course_code)) + else: + return render_template("learning/nuevo_recurso_youtube.html", id_curso=course_code, id_seccion=seccion, form=form) + + +@lms_app.route("/course/resource///increment/") +@login_required +@perfil_requerido("instructor") +def incrementar_indice_recurso(cource_code, seccion_id, indice): + """Actualiza indice de recursos.""" + modificar_indice_seccion( + seccion_id=seccion_id, + indice=int(indice), + task="decrement", + ) + return redirect(url_for("curso", course_code=cource_code)) + + +@lms_app.route("/course/resource///decrement/") +@login_required +@perfil_requerido("instructor") +def reducir_indice_recurso(cource_code, seccion_id, indice): + """Actualiza indice de recursos.""" + modificar_indice_seccion( + seccion_id=seccion_id, + indice=int(indice), + task="increment", + ) + return redirect(url_for("curso", course_code=cource_code)) + + +@lms_app.route("/delete_recurso///") +@login_required +@perfil_requerido("instructor") +def eliminar_recurso(curso_id, seccion, id_): + """Elimina una seccion del curso.""" + CursoRecurso.query.filter(CursoRecurso.codigo == id_).delete() + database.session.commit() + reorganiza_indice_seccion(seccion=seccion) + return redirect(url_for("curso", course_code=curso_id)) + + @lms_app.route("/courses") @lms_app.route("/cursos") @login_required def cursos(): """Pagina principal del curso.""" if current_user.tipo == "admin": - lista_cursos = Curso.query.paginate( - request.args.get("page", default=1, type=int), MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, False + lista_cursos = database.paginate( + database.select(Curso), + page=request.args.get("page", default=1, type=int), + max_per_page=MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, + count=True, ) else: try: - lista_cursos = ( - Curso.query.join(Curso.creado_por) - .filter(Usuario.id == current_user.id) - .paginate(request.args.get("page", default=1, type=int), MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, False) + lista_cursos = database.paginate( + database.select(Curso).join(DocenteCurso).filter(DocenteCurso.usuario == current_user.usuario), + page=request.args.get("page", default=1, type=int), + max_per_page=MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, + count=True, ) + except ArgumentError: lista_cursos = None return render_template("learning/curso_lista.html", consulta=lista_cursos) @lms_app.route("/course/") -@lms_app.route("/curso") def curso(course_code): """Pagina principal del curso.""" return render_template( "learning/curso.html", curso=Curso.query.filter_by(codigo=course_code).first(), - secciones=CursoSeccion.query.filter_by(curso=course_code).all(), + secciones=CursoSeccion.query.filter_by(curso=course_code).order_by(CursoSeccion.indice).all(), recursos=CursoRecurso.query.filter_by(curso=course_code).all(), ) @@ -780,8 +627,11 @@ def curso(course_code): @perfil_requerido("admin") def usuarios(): """Lista de usuarios con acceso a al aplicación.""" - CONSULTA = Usuario.query.paginate( - request.args.get("page", default=1, type=int), MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, False + CONSULTA = database.paginate( + database.select(Usuario), + page=request.args.get("page", default=1, type=int), + max_per_page=MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, + count=True, ) return render_template( @@ -795,8 +645,11 @@ def usuarios(): @perfil_requerido("admin") def usuarios_inactivos(): """Lista de usuarios con acceso a al aplicación.""" - CONSULTA = Usuario.query.filter_by(activo=False).paginate( - request.args.get("page", default=1, type=int), MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, False + CONSULTA = database.paginate( + database.select(Usuario).filter_by(activo=False), + page=request.args.get("page", default=1, type=int), + max_per_page=MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA, + count=True, ) return render_template( @@ -855,12 +708,47 @@ def inactivar_usuario(user_id): @perfil_requerido("admin") def eliminar_usuario(user_id): """Elimina un usuario por su id y redirecciona a la vista dada.""" - perfil_usuario = Usuario.query.filter(Usuario.usuario == user_id) - perfil_usuario.delete() + Usuario.query.filter(Usuario.usuario == user_id).delete() database.session.commit() return redirect(url_for(request.args.get("ruta", default="home", type=str))) +@lms_app.route("/delete_seccion//") +@login_required +@perfil_requerido("instructor") +def eliminar_seccion(curso_id, id_): + """Elimina una seccion del curso.""" + CursoSeccion.query.filter(CursoSeccion.codigo == id_).delete() + database.session.commit() + reorganiza_indice_curso(codigo_curso=curso_id) + return redirect(url_for("curso", course_code=curso_id)) + + +@lms_app.route("/delete_curse/") +@login_required +@perfil_requerido("instructor") +def eliminar_curso(course_id): + """Elimina un curso por su id y redirecciona a la vista dada.""" + + try: + # Eliminanos los recursos relacionados al curso seleccionado. + CursoSeccion.query.filter(CursoSeccion.curso == course_id).delete() + CursoRecurso.query.filter(CursoRecurso.curso == course_id).delete() + # Eliminanos los acceso definidos para el curso detallado. + DocenteCurso.query.filter(DocenteCurso.curso == course_id).delete() + ModeradorCurso.query.filter(ModeradorCurso.curso == course_id).delete() + EstudianteCurso.query.filter(EstudianteCurso.curso == course_id).delete() + # Elimanos curso seleccionado. + Curso.query.filter(Curso.codigo == course_id).delete() + database.session.commit() + flash("Curso Eliminado Correctamente.") + except PGProgrammingError: + flash("No se pudo elimiar el curso solicitado.") + except ProgrammingError: + flash("No se pudo elimiar el curso solicitado.") + return redirect(url_for("cursos")) + + @lms_app.route("/change_user_tipo") @login_required @perfil_requerido("admin") @@ -875,7 +763,7 @@ def cambiar_tipo_usario(): @lms_app.route("/change_curse_status") @login_required -@perfil_requerido("admin") +@perfil_requerido("instructor") def cambiar_estatus_curso(): """Actualiza el estatus de un curso.""" cambia_estado_curso_por_id( @@ -887,7 +775,7 @@ def cambiar_estatus_curso(): @lms_app.route("/change_curse_public") @login_required -@perfil_requerido("admin") +@perfil_requerido("instructor") def cambiar_curso_publico(): """Actualiza el estado publico de un curso.""" cambia_curso_publico( @@ -896,5 +784,16 @@ def cambiar_curso_publico(): return redirect(url_for("curso", course_code=request.args.get("curse"))) -# Los servidores WSGI buscan por defecto una app +@lms_app.route("/change_curse_seccion_public") +@login_required +@perfil_requerido("instructor") +def cambiar_seccion_publico(): + """Actualiza el estado publico de un curso.""" + cambia_seccion_publico( + codigo=request.args.get("codigo"), + ) + return redirect(url_for("curso", course_code=request.args.get("course_code"))) + + +# <-------- Servidores WSGI buscan una "app" por defecto --------> app = lms_app diff --git a/now_lms/auth.py b/now_lms/auth.py new file mode 100644 index 00000000..cbf9280b --- /dev/null +++ b/now_lms/auth.py @@ -0,0 +1,49 @@ +# Copyright 2022 BMO Soluciones, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Contributors: +# - William José Moreno Reyes + + +"""Control de acceso a la aplicacion.""" + +# Libreria standar: + + +# Librerias de terceros: + + +# Recursos locales: + +# pylint: disable=R0401 + + +def proteger_passwd(clave): + """Devuelve una contraseña salteada con bcrytp.""" + from bcrypt import hashpw, gensalt + + return hashpw(clave.encode(), gensalt()) + + +def validar_acceso(usuario_id, acceso): + """Verifica el inicio de sesión del usuario.""" + from bcrypt import checkpw + from now_lms.db import Usuario + + registro = Usuario.query.filter_by(usuario=usuario_id).first() + if registro is not None: + clave_validada = checkpw(acceso.encode(), registro.acceso) + else: + clave_validada = False + return clave_validada diff --git a/now_lms/bi.py b/now_lms/bi.py new file mode 100644 index 00000000..a1eddfca --- /dev/null +++ b/now_lms/bi.py @@ -0,0 +1,198 @@ +# Copyright 2022 BMO Soluciones, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Contributors: +# - William José Moreno Reyes + +"""Logica del "negocio".""" + +# pylint: disable=E1101 + + +# < --------------------------------------------------------------------------------------------- > +# Funciones auxiliares parte de la "logica de negocio" de la implementacion. + +# Libreria standar: +from typing import Union + +# Librerias de terceros: +from flask_login import current_user + +# Recursos locales: +from now_lms.db import database, EstudianteCurso, DocenteCurso, ModeradorCurso, Usuario, Curso, CursoSeccion, CursoRecurso + + +def modificar_indice_curso( + codigo_curso: Union[None, str] = None, + task: Union[None, str] = None, + indice: int = 0, +): + """Modica el número de indice de una sección dentro de un curso.""" + + indice_current = indice + indice_next = indice + 1 + indice_back = indice - 1 + + actual = CursoSeccion.query.filter(CursoSeccion.curso == codigo_curso, CursoSeccion.indice == indice_current).first() + superior = CursoSeccion.query.filter(CursoSeccion.curso == codigo_curso, CursoSeccion.indice == indice_next).first() + inferior = CursoSeccion.query.filter(CursoSeccion.curso == codigo_curso, CursoSeccion.indice == indice_back).first() + + if task == "increment": + actual.indice = indice_next + database.session.add(actual) + database.session.commit() + if superior: + superior.indice = indice_current + database.session.add(superior) + database.session.commit() + + else: # task == decrement + if actual.indice != 1: # No convertir indice 1 a 0. + actual.indice = indice_back + database.session.add(actual) + database.session.commit() + if inferior: + inferior.indice = indice_current + database.session.add(inferior) + database.session.commit() + + +def reorganiza_indice_curso(codigo_curso: Union[None, str] = None): + """Al eliminar una sección de un curso se debe generar el indice nuevamente.""" + + secciones = secciones = CursoSeccion.query.filter_by(curso=codigo_curso).order_by(CursoSeccion.indice).all() + if secciones: + indice = 1 + for seccion in secciones: + seccion.indice = indice + database.session.add(seccion) + database.session.commit() + indice = indice + 1 + + +def modificar_indice_seccion( + seccion_id: Union[None, str] = None, + task: Union[None, str] = None, + indice: int = 0, +): + """Modica el número de indice de una sección dentro de un curso.""" + + indice_current = indice + indice_next = indice + 1 + indice_back = indice - 1 + + actual = CursoRecurso.query.filter(CursoRecurso.seccion == seccion_id, CursoRecurso.indice == indice_current).first() + superior = CursoRecurso.query.filter(CursoRecurso.seccion == seccion_id, CursoRecurso.indice == indice_next).first() + inferior = CursoRecurso.query.filter(CursoRecurso.seccion == seccion_id, CursoRecurso.indice == indice_back).first() + + if task == "increment": + actual.indice = indice_next + database.session.add(actual) + database.session.commit() + if superior: + superior.indice = indice_current + database.session.add(superior) + database.session.commit() + + else: # task == decrement + if actual.indice != 1: # No convertir el indice 1 a 0. + actual.indice = indice_back + database.session.add(actual) + database.session.commit() + if inferior: + inferior.indice = indice_current + database.session.add(inferior) + database.session.commit() + + +def reorganiza_indice_seccion(seccion: Union[None, str] = None): + """Al eliminar una sección de un curso se debe generar el indice nuevamente.""" + + recursos = CursoRecurso.query.filter_by(seccion=seccion).order_by(CursoRecurso.indice).all() + if recursos: + indice = 1 + for recurso in recursos: + recurso.indice = indice + database.session.add(seccion) + database.session.commit() + indice = indice + 1 + + +def asignar_curso_a_instructor(curso_codigo: Union[None, str] = None, usuario_id: Union[None, str] = None): + """Asigna un usuario como instructor de un curso.""" + ASIGNACION = DocenteCurso(curso=curso_codigo, usuario=usuario_id, vigente=True, creado_por=current_user.usuario) + database.session.add(ASIGNACION) + database.session.commit() + + +def asignar_curso_a_moderador(curso_codigo: Union[None, str] = None, usuario_id: Union[None, str] = None): + """Asigna un usuario como moderador de un curso.""" + ASIGNACION = ModeradorCurso(usuario=usuario_id, curso=curso_codigo, vigente=True, creado_por=current_user.usuario) + database.session.add(ASIGNACION) + database.session.commit() + + +def asignar_curso_a_estudiante(curso_codigo: Union[None, str] = None, usuario_id: Union[None, str] = None): + """Asigna un usuario como moderador de un curso.""" + ASIGNACION = EstudianteCurso( + creado_por=current_user.usuario, + curso=curso_codigo, + usuario=usuario_id, + vigente=True, + ) + database.session.add(ASIGNACION) + database.session.commit() + + +def cambia_tipo_de_usuario_por_id(id_usuario: Union[None, str] = None, nuevo_tipo: Union[None, str] = None): + """ + Cambia el estatus de un usuario del sistema. + + Los valores reconocidos por el sistema son: admin, user, instructor, moderator. + """ + USUARIO = Usuario.query.filter_by(usuario=id_usuario).first() + USUARIO.tipo = nuevo_tipo + database.session.commit() + + +def cambia_estado_curso_por_id(id_curso: Union[None, str, int] = None, nuevo_estado: Union[None, str] = None): + """ + Cambia el estatus de un curso. + + Los valores reconocidos por el sistema son: draft, public, open, closed. + """ + CURSO = Curso.query.filter_by(codigo=id_curso).first() + CURSO.estado = nuevo_estado + database.session.commit() + + +def cambia_curso_publico(id_curso: Union[None, str, int] = None): + """Cambia el estatus publico de un curso.""" + CURSO = Curso.query.filter_by(codigo=id_curso).first() + if CURSO.publico: + CURSO.publico = False + else: + CURSO.publico = True + database.session.commit() + + +def cambia_seccion_publico(codigo: Union[None, str, int] = None): + """Cambia el estatus publico de una sección.""" + + SECCION = CursoSeccion.query.filter_by(codigo=codigo).first() + if SECCION.estado: + SECCION.estado = False + else: + SECCION.estado = True + database.session.commit() diff --git a/now_lms/config.py b/now_lms/config.py new file mode 100644 index 00000000..7ba273df --- /dev/null +++ b/now_lms/config.py @@ -0,0 +1,105 @@ +# Copyright 2022 BMO Soluciones, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Contributors: +# - William José Moreno Reyes + +"""Configuración de la aplicación.""" +# Libreria standar: +from os import environ, name, path +from pathlib import Path +from typing import Dict + +# Librerias de terceros: +from flask_uploads import IMAGES, UploadSet +from loguru import logger as log + +# Recursos locales: +from now_lms.version import PRERELEASE + +DESARROLLO: bool = ( + (PRERELEASE is not None) or ("FLASK_DEBUG" in environ) or (environ.get("FLASK_ENV") == "development") or ("CI" in environ) +) + +# < --------------------------------------------------------------------------------------------- > +# Directorios de la aplicacion +DIRECTORIO_APP: str = path.abspath(path.dirname(__file__)) +DIRECTORIO_PRINCICIPAL: Path = Path(DIRECTORIO_APP).parent.absolute() +DIRECTORIO_PLANTILLAS: str = path.join(DIRECTORIO_APP, "templates") +DIRECTORIO_ARCHIVOS: str = path.join(DIRECTORIO_APP, "static") +DIRECTORIO_BASE_ARCHIVOS_DE_USUARIO: str = path.join(DIRECTORIO_APP, "static", "files") +DIRECTORIO_ARCHIVOS_PUBLICOS: str = path.join(DIRECTORIO_BASE_ARCHIVOS_DE_USUARIO, "public") +DIRECTORIO_ARCHIVOS_PRIVADOS: str = path.join(DIRECTORIO_BASE_ARCHIVOS_DE_USUARIO, "private") + + +# < --------------------------------------------------------------------------------------------- > +# Directorios utilizados para la carga de archivos. +DIRECTORIO_IMAGENES: str = path.join(DIRECTORIO_ARCHIVOS_PUBLICOS, "img") +CARGA_IMAGENES = UploadSet("photos", IMAGES) + +# < --------------------------------------------------------------------------------------------- > +# Ubicación predeterminada de base de datos SQLITE +if name == "nt": # pragma: no cover + SQLITE: str = "sqlite:///" + str(DIRECTORIO_PRINCICIPAL) + "\\now_lms.db" +else: + SQLITE = "sqlite:///" + str(DIRECTORIO_PRINCICIPAL) + "/now_lms.db" + + +# < --------------------------------------------------------------------------------------------- > +# Configuración de la aplicación, siguiendo "Twelve Factors App" las opciones se leen del entorno +# o se utilizan valores predeterminados. +CONFIGURACION: Dict = { + "ADMIN_USER": environ.get("LMS_USER") or "lms-admin", + "ADMIN_PSWD": environ.get("LMS_PSWD") or "lms-admin", + "SECRET_KEY": environ.get("LMS_KEY") or "dev", + "SQLALCHEMY_DATABASE_URI": environ.get("LMS_DB") or environ.get("DATABASE_URL") or SQLITE, + "SQLALCHEMY_TRACK_MODIFICATIONS": "False", + # Carga de archivos + "UPLOADED_PHOTOS_DEST": DIRECTORIO_IMAGENES, +} + +if DESARROLLO: # pragma: no cover + log.warning("Opciones de desarrollo detectadas, revise su configuración.") + + +if environ.get("SQLALCHEMY_ECHO"): + CONFIGURACION["SQLALCHEMY_ECHO"] = True + + +# Corrige URI de conexion a la base de datos si el usuario omite el drive apropiado. +if CONFIGURACION.get("SQLALCHEMY_DATABASE_URI"): # pragma: no cover + # En Heroku va a estar disponible psycopg2. + # - https://devcenter.heroku.com/articles/connecting-heroku-postgres#connecting-in-python + # - https://devcenter.heroku.com/changelog-items/2035 + if (environ.get("DYNO")) and ("postgres:" in CONFIGURACION.get("SQLALCHEMY_DATABASE_URI")): # type: ignore[operator] + DBURI: str = str( + "postgresql" + CONFIGURACION.get("SQLALCHEMY_DATABASE_URI")[8:] + "?sslmode=require" # type: ignore[index] + ) + CONFIGURACION["SQLALCHEMY_DATABASE_URI"] = DBURI + + # Servicios como Elephantsql, Digital Ocean proveen una direccion de corrección que comienza con "postgres" + # esta va a fallar con SQLAlchemy, se prefiere el drive pg8000 que no requere compilarse. + elif "postgres:" in CONFIGURACION.get("SQLALCHEMY_DATABASE_URI"): # type: ignore[operator] + DBURI = "postgresql+pg8000" + CONFIGURACION.get("SQLALCHEMY_DATABASE_URI")[8:] # type: ignore[index] + CONFIGURACION["SQLALCHEMY_DATABASE_URI"] = DBURI + + # Agrega driver de mysql: + # - https://docs.sqlalchemy.org/en/14/dialects/mysql.html#module-sqlalchemy.dialects.mysql.pymysql + elif "mysql:" in CONFIGURACION.get("SQLALCHEMY_DATABASE_URI"): # type: ignore[operator] + DBURI = "mysql+pymysql" + CONFIGURACION.get("SQLALCHEMY_DATABASE_URI")[5:] # type: ignore[index] + CONFIGURACION["SQLALCHEMY_DATABASE_URI"] = DBURI + + elif "mariadb:" in CONFIGURACION.get("SQLALCHEMY_DATABASE_URI"): # type: ignore[operator] + DBURI = "mariadb+pymysql" + CONFIGURACION.get("SQLALCHEMY_DATABASE_URI")[7:] # type: ignore[index] + CONFIGURACION["SQLALCHEMY_DATABASE_URI"] = DBURI diff --git a/now_lms/db.py b/now_lms/db.py new file mode 100644 index 00000000..026dac1e --- /dev/null +++ b/now_lms/db.py @@ -0,0 +1,270 @@ +# Copyright 2022 BMO Soluciones, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Contributors: +# - William José Moreno Reyes + +"""Definición de base de datos.""" + +# pylint: disable=E1101 + +# Libreria standar: +from typing import Union + +# Librerias de terceros: +from flask_login import current_user, UserMixin +from flask_sqlalchemy import SQLAlchemy +from loguru import logger as log + +# Recursos locales: +from now_lms.auth import proteger_passwd +from now_lms.config import CONFIGURACION + + +database: SQLAlchemy = SQLAlchemy() + +# < --------------------------------------------------------------------------------------------- > +# Base de datos relacional + +MAXIMO_RESULTADOS_EN_CONSULTA_PAGINADA: int = 10 +LLAVE_FORONEA_CURSO: str = "curso.codigo" +LLAVE_FORONEA_USUARIO: str = "usuario.usuario" +LLAVE_FORANEA_SECCION: str = "curso_seccion.codigo" + + +# pylint: disable=too-few-public-methods +# pylint: disable=no-member +class BaseTabla: + """Columnas estandar para todas las tablas de la base de datos.""" + + # Pistas de auditoria comunes a todas las tablas. + id = database.Column(database.Integer(), primary_key=True, nullable=True) + status = database.Column(database.String(50), nullable=True) + creado = database.Column(database.DateTime, default=database.func.now(), nullable=False) + creado_por = database.Column(database.String(15), nullable=True) + modificado = database.Column(database.DateTime, default=database.func.now(), onupdate=database.func.now(), nullable=True) + modificado_por = database.Column(database.String(15), nullable=True) + + +class Usuario(UserMixin, database.Model, BaseTabla): # type: ignore[name-defined] + """Una entidad con acceso al sistema.""" + + # Información Básica + __table_args__ = (database.UniqueConstraint("usuario", name="usuario_unico"),) + usuario = database.Column(database.String(150), nullable=False, index=True) + acceso = database.Column(database.LargeBinary(), nullable=False) + nombre = database.Column(database.String(100)) + apellido = database.Column(database.String(100)) + correo_electronico = database.Column(database.String(100)) + # Tipo puede ser: admin, user, instructor, moderator + tipo = database.Column(database.String(20)) + activo = database.Column(database.Boolean()) + genero = database.Column(database.String(1)) + nacimiento = database.Column(database.Date()) + + +class Curso(database.Model, BaseTabla): # type: ignore[name-defined] + """Un curso es la base del aprendizaje en NOW LMS.""" + + __table_args__ = (database.UniqueConstraint("codigo", name="curso_codigo_unico"),) + nombre = database.Column(database.String(150), nullable=False) + codigo = database.Column(database.String(20), unique=True) + descripcion = database.Column(database.String(500), nullable=False) + # draft, open, closed + estado = database.Column(database.String(10), nullable=False) + # mooc + publico = database.Column(database.Boolean()) + certificado = database.Column(database.Boolean()) + auditable = database.Column(database.Boolean()) + precio = database.Column(database.Numeric()) + capacidad = database.Column(database.Integer()) + fecha_inicio = database.Column(database.Date()) + fecha_fin = database.Column(database.Date()) + duracion = database.Column(database.Integer()) + portada = database.Column(database.String(250), nullable=True, default=None) + nivel = database.Column(database.Integer()) + + +class CursoSeccion(database.Model, BaseTabla): # type: ignore[name-defined] + """Los cursos tienen secciones para dividir el contenido en secciones logicas.""" + + __table_args__ = (database.UniqueConstraint("codigo", name="curso_seccion_unico"),) + codigo = database.Column(database.String(32), unique=False) + curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) + rel_curso = database.relationship("Curso", foreign_keys=curso) + nombre = database.Column(database.String(100), nullable=False) + descripcion = database.Column(database.String(250), nullable=False) + indice = database.Column(database.Integer()) + # 0: Borrador, 1: Publico + estado = database.Column(database.Boolean()) + + +class CursoRecurso(database.Model, BaseTabla): # type: ignore[name-defined] + """Una sección de un curso consta de una serie de recursos.""" + + __table_args__ = (database.UniqueConstraint("codigo", name="curso_recurso_unico"),) + indice = database.Column(database.Integer()) + codigo = database.Column(database.String(32), unique=False) + seccion = database.Column(database.String(32), database.ForeignKey(LLAVE_FORANEA_SECCION), nullable=False) + curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) + rel_curso = database.relationship("Curso", foreign_keys=curso) + nombre = database.Column(database.String(150), nullable=False) + descripcion = database.Column(database.String(250), nullable=False) + # link, youtube, text, file + tipo = database.Column(database.String(150), nullable=False) + # Youtube + youtube_url = database.Column(database.String(50), unique=False) + # Vimeo + vimeo_url = database.Column(database.String(50), unique=False) + + +class Files(database.Model, BaseTabla): # type: ignore[name-defined] + """Listado de archivos que se han cargado a la aplicacion.""" + + archivo = database.Column(database.String(100), nullable=False) + tipo = database.Column(database.String(15), nullable=False) + hashtag = database.Column(database.String(50), nullable=False) + url = database.Column(database.String(100), nullable=False) + + +class DocenteCurso(database.Model, BaseTabla): # type: ignore[name-defined] + """Uno o mas usuario de tipo intructor pueden estar a cargo de un curso.""" + + curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) + usuario = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_USUARIO), nullable=False) + vigente = database.Column(database.Boolean()) + + +class ModeradorCurso(database.Model, BaseTabla): # type: ignore[name-defined] + """Uno o mas usuario de tipo moderator pueden estar a cargo de un curso.""" + + curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) + usuario = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_USUARIO), nullable=False) + vigente = database.Column(database.Boolean()) + + +class EstudianteCurso(database.Model, BaseTabla): # type: ignore[name-defined] + """Uno o mas usuario de tipo user pueden estar a cargo de un curso.""" + + curso = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_CURSO), nullable=False) + usuario = database.Column(database.String(10), database.ForeignKey(LLAVE_FORONEA_USUARIO), nullable=False) + vigente = database.Column(database.Boolean()) + + +class Configuracion(database.Model, BaseTabla): # type: ignore[name-defined] + """ + Repositorio Central para la configuración de la aplicacion. + + Realmente esta tabla solo va a contener un registro con una columna para cada opción, en las plantillas + va a estar disponible como la variable global config. + """ + + titulo = database.Column(database.String(150), nullable=False) + descripcion = database.Column(database.String(500), nullable=False) + # Uno de mooc, school, training + modo = database.Column(database.String(500), nullable=False, default="mooc") + # Pagos en linea + paypal_key = database.Column(database.String(150), nullable=True) + stripe_key = database.Column(database.String(150), nullable=True) + # Micelaneos + dev_docs = database.Column(database.Boolean(), default=False) + # Permitir al usuario cargar archivos + file_uploads = database.Column(database.Boolean(), default=False) + + +# < --------------------------------------------------------------------------------------------- > +# Funciones auxiliares relacionadas a contultas de la base de datos. + + +def verifica_docente_asignado_a_curso(id_curso: Union[None, str] = None): + """Si el usuario no esta asignado como docente al curso devuelve None.""" + if current_user.is_authenticated: + return DocenteCurso.query.filter(DocenteCurso.usuario == current_user.usuario, DocenteCurso.curso == id_curso) + else: + return False + + +def verifica_moderador_asignado_a_curso(id_curso: Union[None, str] = None): + """Si el usuario no esta asignado como moderador al curso devuelve None.""" + if current_user.is_authenticated: + return ModeradorCurso.query.filter(ModeradorCurso.usuario == current_user.usuario, ModeradorCurso.curso == id_curso) + else: + return False + + +def verifica_estudiante_asignado_a_curso(id_curso: Union[None, str] = None): + """Si el usuario no esta asignado como estudiante al curso devuelve None.""" + if current_user.is_authenticated: + return EstudianteCurso.query.filter(EstudianteCurso.usuario == current_user.usuario, EstudianteCurso.curso == id_curso) + else: + return False + + +def crear_cursos_predeterminados(): + """Crea en la base de datos un curso de demostración.""" + log.info("Creando curso de demostración.") + course1 = Curso(nombre="Demo", codigo="demo", descripcion="This is a demo", estado="draft") + course2 = Curso(nombre="Course 1", codigo="course1", descripcion="This is the first course.", estado="open") + course3 = Curso(nombre="Course 2", codigo="course2", descripcion="This is the first course.", estado="closed") + database.session.add(course1) + database.session.add(course2) + database.session.add(course3) + database.session.commit() + + +def crear_usuarios_predeterminados(): + """Crea en la base de datos los usuarios iniciales.""" + log.info("Creando usuario administrador.") + administrador = Usuario( + usuario=CONFIGURACION.get("ADMIN_USER"), + acceso=proteger_passwd(CONFIGURACION.get("ADMIN_PSWD")), + tipo="admin", + nombre="System", + apellido="Admin", + activo=True, + ) + # Crea un usuario de cada perfil (admin, user, instructor, moderator) + # por defecto desactivados. + demo_user1 = Usuario( + usuario="student", + acceso=proteger_passwd("studen"), + tipo="user", + nombre="User", + apellido="Student", + correo_electronico="usuario1@mail.com", + activo=False, + ) + demo_user2 = Usuario( + usuario="instructor", + acceso=proteger_passwd("instructor"), + tipo="instructor", + nombre="User", + apellido="Instructor", + correo_electronico="usuario2@mail.com", + activo=False, + ) + demo_user3 = Usuario( + usuario="moderator", + acceso=proteger_passwd("moderator"), + tipo="moderator", + nombre="User", + apellido="Moderator", + correo_electronico="usuario3@mail.com", + activo=False, + ) + database.session.add(administrador) + database.session.add(demo_user1) + database.session.add(demo_user2) + database.session.add(demo_user3) + database.session.commit() diff --git a/now_lms/forms.py b/now_lms/forms.py new file mode 100644 index 00000000..dd21c03b --- /dev/null +++ b/now_lms/forms.py @@ -0,0 +1,87 @@ +# Copyright 2022 BMO Soluciones, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Contributors: +# - William José Moreno Reyes + +"""Definición de formularios.""" +# Libreria standar: + +# Librerias de terceros: +from flask_wtf import FlaskForm +from wtforms import BooleanField, DecimalField, DateField, IntegerField, PasswordField, SelectField, StringField, SubmitField +from wtforms.validators import DataRequired + +# Recursos locales: + + +# < --------------------------------------------------------------------------------------------- > +# Definición de formularios +class LoginForm(FlaskForm): + """Formulario de inicio de sesión.""" + + usuario = StringField(validators=[DataRequired()]) + acceso = PasswordField(validators=[DataRequired()]) + inicio_sesion = SubmitField() + + +class LogonForm(FlaskForm): + """Formulario para crear un nuevo usuario.""" + + usuario = StringField(validators=[DataRequired()]) + acceso = PasswordField(validators=[DataRequired()]) + nombre = StringField(validators=[DataRequired()]) + apellido = StringField(validators=[DataRequired()]) + correo_electronico = StringField(validators=[DataRequired()]) + + +class CurseForm(FlaskForm): + """Formulario para crear un nuevo curso.""" + + nombre = StringField(validators=[DataRequired()]) + codigo = StringField(validators=[DataRequired()]) + descripcion = StringField(validators=[DataRequired()]) + publico = BooleanField(validators=[]) + auditable = BooleanField(validators=[]) + certificado = BooleanField(validators=[]) + precio = DecimalField(validators=[]) + capacidad = IntegerField(validators=[]) + fecha_inicio = DateField(validators=[]) + fecha_fin = DateField(validators=[]) + duracion = IntegerField(validators=[]) + nivel = SelectField("User", choices=[(0, "Introductorio"), (1, "Principiante"), (2, "Intermedio"), (2, "Avanzado")]) + + +class CursoRecursoForm(FlaskForm): + """Formulario para crear un nuevo recurso.""" + + tipo = SelectField( + "Tipo", + choices=[("link", "Vinculo"), ("youtube", "Vídeo en YouTube"), ("file", "Archivo"), ("text", "Texto")], + ) + + +class CursoSeccionForm(FlaskForm): + """Formulario para crear una nueva sección.""" + + nombre = StringField(validators=[DataRequired()]) + descripcion = StringField(validators=[DataRequired()]) + + +class CursoRecursoVideoYoutube(FlaskForm): + """Formulario para crear una nueva sección.""" + + nombre = StringField(validators=[DataRequired()]) + descripcion = StringField(validators=[DataRequired()]) + youtube_url = StringField(validators=[DataRequired()]) diff --git a/now_lms/static/package-lock.json b/now_lms/static/package-lock.json new file mode 100644 index 00000000..66c2c303 --- /dev/null +++ b/now_lms/static/package-lock.json @@ -0,0 +1,69 @@ +{ + "name": "now_lms", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "now_lms", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "bootstrap": "^5.2.2", + "bootstrap-icons": "^1.10.2" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", + "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/bootstrap": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.2.tgz", + "integrity": "sha512-dEtzMTV71n6Fhmbg4fYJzQsw1N29hJKO1js5ackCgIpDcGid2ETMGC6zwSYw09v05Y+oRdQ9loC54zB1La3hHQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.6" + } + }, + "node_modules/bootstrap-icons": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.2.tgz", + "integrity": "sha512-PTPYadRn1AMGr+QTSxe4ZCc+Wzv9DGZxbi3lNse/dajqV31n2/wl/7NX78ZpkvFgRNmH4ogdIQPQmxAfhEV6nA==" + } + }, + "dependencies": { + "@popperjs/core": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", + "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==", + "peer": true + }, + "bootstrap": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.2.tgz", + "integrity": "sha512-dEtzMTV71n6Fhmbg4fYJzQsw1N29hJKO1js5ackCgIpDcGid2ETMGC6zwSYw09v05Y+oRdQ9loC54zB1La3hHQ==", + "requires": {} + }, + "bootstrap-icons": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.2.tgz", + "integrity": "sha512-PTPYadRn1AMGr+QTSxe4ZCc+Wzv9DGZxbi3lNse/dajqV31n2/wl/7NX78ZpkvFgRNmH4ogdIQPQmxAfhEV6nA==" + } + } +} diff --git a/now_lms/static/package.json b/now_lms/static/package.json new file mode 100644 index 00000000..d151aea8 --- /dev/null +++ b/now_lms/static/package.json @@ -0,0 +1,23 @@ +{ + "name": "now_lms", + "version": "1.0.0", + "description": "LMS", + "main": "index.js", + "scripts": { + "test": "test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bmosoluciones/now-lms.git" + }, + "author": "", + "license": "MIT", + "bugs": { + "url": "https://github.com/bmosoluciones/now-lms/issues" + }, + "homepage": "https://github.com/bmosoluciones/now-lms#readme", + "dependencies": { + "bootstrap": "^5.2.2", + "bootstrap-icons": "^1.10.2" + } +} diff --git a/now_lms/templates/admin/inactive_users.html b/now_lms/templates/admin/inactive_users.html index cd601740..4d8b7feb 100644 --- a/now_lms/templates/admin/inactive_users.html +++ b/now_lms/templates/admin/inactive_users.html @@ -8,22 +8,7 @@ {{ macros.headertags() }} Usuarios inativos - + {{ macros.local_style() }} diff --git a/now_lms/templates/admin/users.html b/now_lms/templates/admin/users.html index 9a6c8e6f..fb232323 100644 --- a/now_lms/templates/admin/users.html +++ b/now_lms/templates/admin/users.html @@ -8,22 +8,7 @@ {{ macros.headertags() }} Lista de usuarios - + {{ macros.local_style() }} @@ -79,13 +64,55 @@

Usuarios registrados en el sistema.

+ + {% if item.tipo == "user" %} + + + Usuario + + + {% elif item.tipo == "moderator" %} + + + Moderador + + + {% elif item.tipo == "instructor" %} + + + Moderador + + + {% else %} + + + Instructor + + + {% endif %} + + - {{ item.tipo }} + {{ item.tipo | capitalize }} + {% if item.activo %} + + + Usuario Activo + + + {% else %} + + + Usuario Inactivo + + + {% endif %} + - {{ item.activo| replace("True", "Activo") | replace("False", "Inactivo") }} + {{ item.activo | replace("True", "Activo") | replace("False", "Inactivo") }} diff --git a/now_lms/templates/auth/logon.html b/now_lms/templates/auth/logon.html index 8be82e6c..524ed55b 100644 --- a/now_lms/templates/auth/logon.html +++ b/now_lms/templates/auth/logon.html @@ -50,11 +50,11 @@

Crear nuevo usuario.

{{ form.usuario(class="form-control", id="usuario", placeholder="Usuario") }} - {{ form.nombre(class="form-control", id="usuario", placeholder="Nombre") }} + {{ form.nombre(class="form-control", id="nombre", placeholder="Nombre") }} - {{ form.apellido(class="form-control", id="usuario", placeholder="Apellido") }} + {{ form.apellido(class="form-control", id="apellido", placeholder="Apellido") }} - {{ form.correo_electronico(class="form-control", id="corre_electronico", placeholder="Correo Electronico") }} + {{ form.correo_electronico(class="form-control", id="correo_electronico", placeholder="Correo Electronico") }} {{ form.acceso(class="form-control", id="acceso", placeholder="Contraseña") }} diff --git a/now_lms/templates/inicio/mooc.html b/now_lms/templates/inicio/mooc.html index 72ebe2c6..6ee7aab0 100644 --- a/now_lms/templates/inicio/mooc.html +++ b/now_lms/templates/inicio/mooc.html @@ -8,22 +8,7 @@ {{ macros.headertags() }} {{ config.titulo }} - + {{ macros.local_style() }} @@ -32,6 +17,8 @@
+ {{ macros.notify() }} +
diff --git a/now_lms/templates/inicio/panel.html b/now_lms/templates/inicio/panel.html index 93c960dc..492ecaba 100644 --- a/now_lms/templates/inicio/panel.html +++ b/now_lms/templates/inicio/panel.html @@ -8,22 +8,7 @@ {{ macros.headertags() }} Panel del usuario - + {{ macros.local_style() }} @@ -31,6 +16,9 @@ {{ macros.navbar() }}
+ + {{ macros.notify() }} +

Bienvenido {% if current_user.tipo == "admin" %} diff --git a/now_lms/templates/inicio/perfil.html b/now_lms/templates/inicio/perfil.html index 2b1ff704..be54621f 100644 --- a/now_lms/templates/inicio/perfil.html +++ b/now_lms/templates/inicio/perfil.html @@ -8,22 +8,7 @@ {{ macros.headertags() }} Usuario - {{ perfil.usuario }} - + {{ macros.local_style() }} @@ -31,6 +16,9 @@ {{ macros.navbar() }}
+ + {{ macros.notify() }} +
@@ -39,17 +27,17 @@

Perfil del usuario {{ perfil.usuario }}

{% if perfil.tipo != "user" %} - + Establecer como Estudiante {% endif %} {% if perfil.tipo != "moderator" %} - + Establecer como Moderador {% endif %} {% if perfil.tipo != "instructor" %} - + Establecer como Instructor {% endif %} @@ -66,11 +54,11 @@

Perfil del usuario {{ perfil.usuario }}

{% if perfil.activo %} - + Inactivar Usuario {% else %} - + Activar Usuario {% endif %} diff --git a/now_lms/templates/learning/asignacion.html b/now_lms/templates/learning/asignacion.html index c9b8b687..5cce6535 100644 --- a/now_lms/templates/learning/asignacion.html +++ b/now_lms/templates/learning/asignacion.html @@ -8,21 +8,7 @@ {{ macros.headertags() }} {{ title }} - + {{ macros.local_style() }} @@ -31,6 +17,8 @@

+ {{ macros.notify() }} +
diff --git a/now_lms/templates/learning/cuestionario.html b/now_lms/templates/learning/cuestionario.html index c9b8b687..5cce6535 100644 --- a/now_lms/templates/learning/cuestionario.html +++ b/now_lms/templates/learning/cuestionario.html @@ -8,21 +8,7 @@ {{ macros.headertags() }} {{ title }} - + {{ macros.local_style() }} @@ -31,6 +17,8 @@
+ {{ macros.notify() }} +
diff --git a/now_lms/templates/learning/curso.html b/now_lms/templates/learning/curso.html index 356f6607..64389442 100644 --- a/now_lms/templates/learning/curso.html +++ b/now_lms/templates/learning/curso.html @@ -1,7 +1,8 @@ {% import "macros.html" as macros %} -{% set docente = docente_asignado(id_curso=curso.codigo) %} -{% set moderador = moderador_asignado(id_curso=curso.codigo) %} -{% set estudiante = estudiante_asignado(id_curso=curso.codigo) %} +{% set permitir_docente = docente_asignado(id_curso=curso.codigo) %} +{% set permitir_moderador = moderador_asignado(id_curso=curso.codigo) %} +{% set permitir_estudiante = estudiante_asignado(id_curso=curso.codigo) %} +{% set permitir_editar = current_user.tipo == "admin" or permitir_docente %} @@ -11,22 +12,7 @@ {{ macros.headertags() }} {{ curso.codigo | upper }} - {{ curso.nombre | title }} - + {{ macros.local_style() }} @@ -35,20 +21,19 @@
+ {{ macros.notify() }} +

{{ curso.codigo }} - {{ curso.nombre }}

- {% if docente %} - + {% if permitir_docente %} + Borrador - Publicar - - Abrir @@ -63,6 +48,9 @@

{{ curso.codigo }} - {{ curso.nombre }}

Agregar al Sitio Web. {% endif %} + + Eliminar + {% endif %}

@@ -144,45 +132,87 @@

{{ curso.nombre }}

-

Contenido del curso.

- + {% if permitir_editar %} +
+ + Nueva Sección + +
+ {% endif %} -
-
-

- -

-
-
Placeholder content for this accordion, which is intended to demonstrate the .accordion-flush class. This is the first item's accordion body.
-
-
-
-

- -

-
-
Placeholder content for this accordion, which is intended to demonstrate the .accordion-flush class. This is the second item's accordion body. Let's imagine this being filled with some actual content.
-
-
-
-

- -

-
-
Placeholder content for this accordion, which is intended to demonstrate the .accordion-flush class. This is the third item's accordion body. Nothing more exciting happening here in terms of content, but just filling up the space to make it look, at least at first glance, a bit more representative of how this would look in a real-world application.
+

Contenido del curso.

+ + {% if secciones %} +
+ {% for seccion in secciones %} +
+

+ +

+
+
+ {% if permitir_editar %} +
+ + Bajar + + + Subir + + {% if seccion.estado == False %} + + Publicar Sección + + {% else %} + + Ocultar Sección + + {% endif %} + + Nuevo Recurso + + + Elimina Sección + +
+ {% endif %} + {{ seccion.descripcion }} +
    + {% for recurso in recursos %} + {% if recurso.seccion == seccion.codigo %} +
  • +
    {{ recurso.nombre }}
    + {% if permitir_editar %} +
    + + Bajar + + + Subir + + + Eliminar + +
    + {% endif %} + {{ recurso.descripcion }} +
  • + {% endif %} + {% endfor %} +
+
+
+ {% endfor %}
-
- - - + {% else %} + + {% endif %}
diff --git a/now_lms/templates/learning/curso_lista.html b/now_lms/templates/learning/curso_lista.html index c77a29aa..812be310 100644 --- a/now_lms/templates/learning/curso_lista.html +++ b/now_lms/templates/learning/curso_lista.html @@ -8,22 +8,8 @@ {{ macros.headertags() }} Listado de cursos. - + + {{ macros.local_style() }} @@ -31,6 +17,9 @@ {{ macros.navbar() }}
+ + {{ macros.notify() }} +
@@ -58,6 +47,7 @@

Lista de Cursos Disponibles.

Fecha Inicio Fecha Fin Estado + Público {% for item in consulta.items -%} @@ -84,10 +74,51 @@

Lista de Cursos Disponibles.

+ + {% if item.estado == "draft" %} + + + Borrador + + + {% elif item.estado == "open" %} + + + Abierto + + + {% else %} + + + Cerrado + + + {% endif %} + {{ item.estado | capitalize }} + + + {% if item.publico %} + + + Curso Publico + + + {% else %} + + + Curso Privado + + + {% endif %} + + + {{ item.publico | replace("True", "Publico") | replace("False", "Privado") | replace("None", "Privado") }} + + {% endfor %} diff --git a/now_lms/templates/learning/nuevo_curso.html b/now_lms/templates/learning/nuevo_curso.html index d6913ff9..d7b1e6df 100644 --- a/now_lms/templates/learning/nuevo_curso.html +++ b/now_lms/templates/learning/nuevo_curso.html @@ -8,22 +8,7 @@ {{ macros.headertags() }} Crear un nuevo curso - + {{ macros.local_style() }} @@ -34,6 +19,8 @@
+ {{ macros.notify() }} +

Crear nuevo curso.

diff --git a/now_lms/templates/learning/nuevo_recurso.html b/now_lms/templates/learning/nuevo_recurso.html new file mode 100644 index 00000000..2705fd24 --- /dev/null +++ b/now_lms/templates/learning/nuevo_recurso.html @@ -0,0 +1,138 @@ +{% import "macros.html" as macros %} + + + + + + + {{ macros.headertags() }} + Crear un nuevo recurso + + {{ macros.local_style() }} + + + + + + + {{ macros.navbar() }} + +
+ + {{ macros.notify() }} + +
+

Seleccione un elemento a añadir al curso.

+ +
+
+ +
+

Youtube Vídeo.

+

Una lección en video alojada en YouTube.

+

+ + Agregar al curso. + +

+
+
+
+ +
+

Vimeo Vídeo.

+

Una lección en video alojada en Vimeo.

+
+
+
+ +
+

Elemento Externo.

+

Comparta un link a un elemento en una página web externa.

+
+
+
+ +
+

Cargar Elemento.

+

Adjunte un archivo para compartirlo con sus estudiantes.

+
+
+
+ +
+

Leccion de Texto.

+

Una lectura para compartir con sus estudiantes.

+
+
+
+ +
+

Google Drive.

+

Agregue un recurso alojado en Google Drive.

+
+
+
+ +
+

Diapositivas.

+

Agregue una presentación sencilla sin salir de la aplicación.

+
+
+
+ +
+

HTLM Personalizado.

+

Agregue directamente un recurso externo a su curso.

+
+
+
+ +

Agregue una evaluación al curso.

+ +
+
+ +
+

Selección Multiple.

+

Agregue una evaluación con multiples opciones de las cuales una es la correcta. + Puede crear una evaluación con solo dos opciones para respuestas de tipo verdadero falso. +

+
+
+
+ +
+

Preguntas de Desarrollo.

+

El estudiante debera desarrollar su respuesta, la calificación de la evaluación + debera agregarla manualmente. +

+
+
+
+ +
+

Link a Recurso Externo.

+

El estudiante debera publicar su trabajo en otro sitio y compartir el acceso al mismo, debera + asignar manualmente la calificación. +

+
+
+
+ +
+

Carga de Archivos.

+

El estudiante debera publicar su trabajo, debera asignar manualmente la calificación. +

+
+
+
+ +
+ +
+ + + + + diff --git a/now_lms/templates/learning/nuevo_recurso_youtube.html b/now_lms/templates/learning/nuevo_recurso_youtube.html new file mode 100644 index 00000000..a144757f --- /dev/null +++ b/now_lms/templates/learning/nuevo_recurso_youtube.html @@ -0,0 +1,54 @@ +{% import "macros.html" as macros %} + + + + + + + {{ macros.headertags() }} + Crear un nuevo curso + + {{ macros.local_style() }} + + + + + + + {{ macros.navbar() }} + +
+ + {{ macros.notify() }} + +
+

Agregar un nuevo vídeo en Youtube.

+ + {{ form.csrf_token }} +
+
+ + {{ form.nombre(class="form-control", id="nombre", placeholder="Titulo del Video") }} +
+ +
+ + {{ form.youtube_url(class="form-control", id="youtube_url", placeholder="Dirección del Vídeo") }} +
+ +
+ + {{ form.descripcion(class="form-control", id="descripcion", placeholder="Descripción del Video") }} +
+ + + +
+ + +
+ + + + + diff --git a/now_lms/templates/learning/nuevo_seccion.html b/now_lms/templates/learning/nuevo_seccion.html new file mode 100644 index 00000000..f9a74f3f --- /dev/null +++ b/now_lms/templates/learning/nuevo_seccion.html @@ -0,0 +1,54 @@ +{% import "macros.html" as macros %} + + + + + + + {{ macros.headertags() }} + Crear una nueva Sección. + + {{ macros.local_style() }} + + + + + + + {{ macros.navbar() }} + +
+ + {{ macros.notify() }} + +
+

Crear una nueva sección.

+
+ {{ form.csrf_token }} +
+ +
+ + {{ form.nombre(class="form-control", id="nombre", placeholder="Nombre de la Nueva Sección") }} +
+ +
+ + {{ form.descripcion(class="form-control", id="descripcion", placeholder="Descripción de la nueva Sección.") }} +
+ +
+ +
+ + +
+
+ + +
+ + + + + diff --git a/now_lms/templates/learning/nuevo_usuario.html b/now_lms/templates/learning/nuevo_usuario.html index ec152273..1c525b17 100644 --- a/now_lms/templates/learning/nuevo_usuario.html +++ b/now_lms/templates/learning/nuevo_usuario.html @@ -8,22 +8,7 @@ {{ macros.headertags() }} Crear un nuevo usuario - + {{ macros.local_style() }} @@ -34,6 +19,8 @@
+ {{ macros.notify() }} +

Crear nuevo Usuario.

diff --git a/now_lms/templates/learning/programa.html b/now_lms/templates/learning/programa.html index c9b8b687..5cce6535 100644 --- a/now_lms/templates/learning/programa.html +++ b/now_lms/templates/learning/programa.html @@ -8,21 +8,7 @@ {{ macros.headertags() }} {{ title }} - + {{ macros.local_style() }} @@ -31,6 +17,8 @@
+ {{ macros.notify() }} +
diff --git a/now_lms/templates/learning/prueba.html b/now_lms/templates/learning/prueba.html index c9b8b687..5cce6535 100644 --- a/now_lms/templates/learning/prueba.html +++ b/now_lms/templates/learning/prueba.html @@ -8,21 +8,7 @@ {{ macros.headertags() }} {{ title }} - + {{ macros.local_style() }} @@ -31,6 +17,8 @@
+ {{ macros.notify() }} +
diff --git a/now_lms/templates/learning/recurso.html b/now_lms/templates/learning/recurso.html index c9b8b687..5cce6535 100644 --- a/now_lms/templates/learning/recurso.html +++ b/now_lms/templates/learning/recurso.html @@ -8,21 +8,7 @@ {{ macros.headertags() }} {{ title }} - + {{ macros.local_style() }} @@ -31,6 +17,8 @@
+ {{ macros.notify() }} +
diff --git a/now_lms/templates/learning/tarea.html b/now_lms/templates/learning/tarea.html index c9b8b687..5cce6535 100644 --- a/now_lms/templates/learning/tarea.html +++ b/now_lms/templates/learning/tarea.html @@ -8,21 +8,7 @@ {{ macros.headertags() }} {{ title }} - + {{ macros.local_style() }} @@ -31,6 +17,8 @@
+ {{ macros.notify() }} +
diff --git a/now_lms/templates/macros.html b/now_lms/templates/macros.html index f3b11074..4d5f4ce0 100644 --- a/now_lms/templates/macros.html +++ b/now_lms/templates/macros.html @@ -134,3 +134,49 @@ {% endmacro %} + +{% macro notify() %} + +{% with messages = get_flashed_messages() %} +{% if messages %} +{% for message in messages %} + +
+ +
+ +{% endfor %} +{% endif %} +{% endwith %} + +{% endmacro %} + +{% macro local_style() -%} + + + + +{% endmacro %} + diff --git a/now_lms/templates/perfiles/admin.html b/now_lms/templates/perfiles/admin.html index 83d1d28b..cf941acf 100644 --- a/now_lms/templates/perfiles/admin.html +++ b/now_lms/templates/perfiles/admin.html @@ -31,6 +31,9 @@ {{ macros.navbar() }}
+ + {{ macros.notify() }} +

Panel de Administración.

diff --git a/now_lms/templates/perfiles/estudiante.html b/now_lms/templates/perfiles/estudiante.html index 59d6b155..15ab07e3 100644 --- a/now_lms/templates/perfiles/estudiante.html +++ b/now_lms/templates/perfiles/estudiante.html @@ -31,6 +31,8 @@
+ {{ macros.notify() }} +
diff --git a/now_lms/templates/perfiles/instructor.html b/now_lms/templates/perfiles/instructor.html index 9b3f86ec..d8615748 100644 --- a/now_lms/templates/perfiles/instructor.html +++ b/now_lms/templates/perfiles/instructor.html @@ -31,6 +31,9 @@ {{ macros.navbar() }}
+ + {{ macros.notify() }} +

Panel del docente.

diff --git a/now_lms/templates/perfiles/moderador.html b/now_lms/templates/perfiles/moderador.html index ed11aabf..8c1902ec 100644 --- a/now_lms/templates/perfiles/moderador.html +++ b/now_lms/templates/perfiles/moderador.html @@ -31,6 +31,8 @@
+ {{ macros.notify() }} +
diff --git a/now_lms/templates/vacio.html b/now_lms/templates/vacio.html index c9b8b687..05db3274 100644 --- a/now_lms/templates/vacio.html +++ b/now_lms/templates/vacio.html @@ -8,21 +8,7 @@ {{ macros.headertags() }} {{ title }} - + {{ macros.local_style() }} diff --git a/package.json b/package.json deleted file mode 100644 index ac671559..00000000 --- a/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "cacao-accounting", - "version": "1.0.0", - "description": "Software contable para micro, pequeñas y medianas empresas.", - "main": "index.js", - "scripts": {}, - "repository": "git+https://github.com/cacao-accounting/cacao-accounting.git", - "author": "William Moreno Reyes", - "license": "ISC", - "bugs": { - "url": "https://github.com/cacao-accounting/cacao-accounting/issues" - }, - "homepage": "https://github.com/cacao-accounting/cacao-accounting#readme", - "dependencies": { - "bootstrap": "^5.1.1", - "bootstrap-icons": "^1.5.0" - } -} diff --git a/setup.py b/setup.py index 4578e16e..a52c4e77 100644 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", ], install_requires=[ diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..ed3e6af2 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1 @@ +sonar.exclusions=tests/* \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 59add92c..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest -from now_lms import init_app, lms_app - - -lms_app.app_context().push() - -@pytest.fixture(scope="package", autouse=True) -def setup_database(): - init_app() diff --git a/tests/test_basicos.py b/tests/test_basicos.py index 667c1409..c3b7dd8e 100644 --- a/tests/test_basicos.py +++ b/tests/test_basicos.py @@ -1,2 +1,60 @@ +# Copyright 2021 BMO Soluciones, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Contributors: +# - William José Moreno Reyes + +from unittest import TestCase +import pytest + + def test_dummy(): + """El proyecto debe poder importarse sin errores.""" import now_lms + + now_lms.init_app() + + +class TestBasicos(TestCase): + def setUp(self): + from now_lms import app + + self.app = app + self.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + self.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + self.app.app_context().push() + + def test_cli(self): + self.app.test_cli_runner() + + +class TestInstanciasDeClases(TestCase): + def setUp(self): + from now_lms import app + + self.app = app + self.app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + self.app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + self.app.app_context().push() + + def test_Flask(self): + from flask import Flask + + self.assertIsInstance(self.app, Flask) + + def test_SQLAlchemy(self): + from now_lms import database + from flask_sqlalchemy import SQLAlchemy + + self.assertIsInstance(database, SQLAlchemy) diff --git a/tests/test_vistas_admin.py b/tests/test_vistas_admin.py new file mode 100644 index 00000000..d4b47831 --- /dev/null +++ b/tests/test_vistas_admin.py @@ -0,0 +1,410 @@ +# Copyright 2020 William José Moreno Reyes +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Contributors: +# - William José Moreno Reyes + +# pylint: disable=redefined-outer-name +import pytest +import now_lms +from now_lms import app, database, init_app, Configuracion, crear_usuarios_predeterminados, crear_cursos_predeterminados, log + +app.config["SECRET_KEY"] = "jgjañlsldaksjdklasjfkjj" +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite://" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +app.config["TESTING"] = True +app.config["WTF_CSRF_ENABLED"] = False +app.config["DEBUG"] = True +app.app_context().push() + + +@pytest.fixture(scope="module", autouse=True) +def lms(): + with app.app_context(): + database.drop_all() + database.create_all() + config = Configuracion( + titulo="NOW LMS", + descripcion="Sistema de aprendizaje en linea.", + ) + database.session.add(config) + database.session.commit() + crear_usuarios_predeterminados() + crear_cursos_predeterminados() + app.app_context().push() + yield app + + +def test_dummy(): + assert app.config["DEBUG"] == True + + +@pytest.fixture +def client(lms): + return app.test_client() + + +@pytest.fixture +def runner(lms): + return app.test_cli_runner() + + +class AuthActions: + def __init__(self, client): + self._client = client + + def login(self): + return self._client.post("/login", data={"usuario": "lms-admin", "acceso": "lms-admin"}) + + def logout(self): + return self._client.get("/salir") + + +@pytest.fixture +def auth(client): + return AuthActions(client) + + +def test_login(client): + response = client.get("/login") + assert b"Inicio" in response.data + assert b"BMO Soluciones" in response.data + + +def test_logon(client): + response = client.get("/logon") + assert b"Crear nuevo usuario." in response.data + + +def test_root(client): + response = client.get("/") + assert b"No hay cursos disponibles en este momento." in response.data + + +def test_app(client, auth): + auth.login() + response = client.get("/panel") + log.error(response.data) + + assert b"Administrador del Sistema." in response.data + + +def test_admin(client, auth): + auth.login() + response = client.get("/admin") + log.error(response.data) + + assert b"Panel de Adminis" in response.data + assert b"Usuarios" in response.data + + +def test_instructor(client, auth): + auth.login() + response = client.get("/instructor") + log.error(response.data) + + assert b"Panel del docente." in response.data + + +def test_moderator(client, auth): + auth.login() + response = client.get("/moderator") + assert response.data + + +def test_student(client, auth): + auth.login() + response = client.get("/student") + assert response.data + + +def test_users(client, auth): + auth.login() + response = client.get("/users") + assert response.data + assert b"Usuarios registrados en el sistema." in response.data + + +def test_users_inactive(client, auth): + auth.login() + response = client.get("/inactive_users") + assert response.data + + assert b"Usuarios pendientes" in response.data + + +def test_nuevo_usuario(client, auth): + auth.login() + response = client.get("/new_user") + assert response.data + + assert b"Crear nuevo Usuario." in response.data + + +def test_cursos(client, auth): + auth.login() + response = client.get("/cursos") + assert response.data + + assert b"Lista de Cursos Disponibles." in response.data + + +def test_perfil(client, auth): + auth.login() + response = client.get("/perfil") + assert response.data + + assert b"Perfil del usuario lms-admin" in response.data + + +def test_user_admin(client, auth): + auth.login() + response = client.get("/user/admin") + assert response.data + + assert b"Perfil del usuario " in response.data + + +def test_activar_usuario(client, auth): + auth.login() + response = client.get("/set_user_as_active/instructor") + + +def test_inactivar_usuario(client, auth): + auth.login() + response = client.get("/set_user_as_inactive/instructor") + + +def test_eliminar_usuario(client, auth): + auth.login() + response = client.get("/delete_user/instructor") + + +def test_crear_usuario(client): + post = client.post( + "/logon", + data={ + "usuario": "test_user", + "nombre": "Testing", + "apellido": "Testing", + "correo_electronico": "testing@cacao-accounting.io", + "acceso": "Akjlkas5a4s6asd", + }, + ) + query = now_lms.Usuario.query.filter_by(usuario="test_user").first() + assert query + # Usuario inactivo por defecto. + assert query.activo is False + + +def test_funciones_usuario(client, auth): + post = client.post( + "/logon", + data={ + "usuario": "test_user1", + "nombre": "Testing", + "apellido": "Testing", + "correo_electronico": "testing@cacao-accounting.io", + "acceso": "Akjlkas5a4s6asd", + }, + ) + query = now_lms.Usuario.query.filter_by(usuario="test_user1").first() + assert query + # Usuario inactivo por defecto. + assert query.activo is False + # Activar usuario + auth.login() + activar = client.get("/set_user_as_active/test_user1") + activar.data + activo = now_lms.Usuario.query.filter_by(usuario="test_user1").first() + assert query.activo is True + from now_lms import cambia_tipo_de_usuario_por_id + + # Establecer como administrador. + cambia_tipo_de_usuario_por_id("test_user1", "admin") + admin = now_lms.Usuario.query.filter_by(usuario="test_user1").first() + assert admin.tipo == "admin" + # Establecer como moderador. + cambia_tipo_de_usuario_por_id("test_user1", "moderator") + admin = now_lms.Usuario.query.filter_by(usuario="test_user1").first() + assert admin.tipo == "moderator" + # Establecer como instructor. + cambia_tipo_de_usuario_por_id("test_user1", "instructor") + admin = now_lms.Usuario.query.filter_by(usuario="test_user1").first() + assert admin.tipo == "instructor" + # Establecer como instructor. + cambia_tipo_de_usuario_por_id("test_user1", "user") + admin = now_lms.Usuario.query.filter_by(usuario="test_user1").first() + assert admin.tipo == "user" + + +def test_crear_curso(client, auth): + auth.login() + # Crear un curso. + post = client.post( + "/new_curse", + data={ + "nombre": "Curso de Prueba", + "codigo": "T-001", + "descripcion": "Curso de Prueba.", + }, + ) + curso = now_lms.Curso.query.filter_by(codigo="T-001").first() + assert curso.nombre == "Curso de Prueba" + assert curso.descripcion == "Curso de Prueba." + # Crear una sección del curso. + post = client.post( + "/course/T-001/new_seccion", + data={ + "nombre": "Seccion de Prueba", + "descripcion": "Seccion de Prueba.", + }, + ) + seccion = now_lms.CursoSeccion.query.filter_by(curso="T-001").first() + assert seccion.nombre == "Seccion de Prueba" + assert seccion.descripcion == "Seccion de Prueba." + client.get("/change_curse_status?curse=T-001&status=draft") + client.get("/change_curse_status?curse=T-001&status=public") + client.get("/change_curse_status?curse=T-001&status=open") + client.get("/change_curse_status?curse=T-001&status=closed") + client.get("/change_curse_public?curse=T-001") + client.get("/change_curse_public?curse=T-001") + publicar_seccion = "/change_curse_seccion_public?course_code=T-001" + "&codigo=" + seccion.codigo + client.get(publicar_seccion) + client.get(publicar_seccion) + eliminar_seccion = "/delete_seccion/T-001/" + seccion.codigo + client.get(eliminar_seccion) + client.get("/delete_curse/T-001") + + +def test_cambiar_curso_publico(client, auth): + auth.login() + client.get("/change_curse_public?curse=demo") + client.get("/change_curse_public?curse=demo") + + +def test_cambiar_estatus_curso(client, auth): + auth.login() + client.get("/change_curse_status?curse=demo&status=draft") + client.get("/change_curse_status?curse=demo&status=public") + client.get("/change_curse_status?curse=demo&status=open") + client.get("/change_curse_status?curse=demo&status=closed") + + +def test_indices_seccion(): + from now_lms import CursoSeccion, modificar_indice_curso, reorganiza_indice_curso + + seccion1 = CursoSeccion( + curso="demo", + nombre="Seccion Prueba A", + descripcion="Seccion Prueba A.", + indice=2, + ) + seccion2 = CursoSeccion( + curso="demo", + nombre="Seccion Prueba B", + descripcion="Seccion Prueba B.", + indice=1, + ) + seccion3 = CursoSeccion( + curso="demo", + nombre="Seccion Prueba C", + descripcion="Seccion Prueba C.", + indice=3, + ) + database.session.add(seccion1) + database.session.add(seccion2) + database.session.add(seccion3) + database.session.commit() + seccion1 = CursoSeccion.query.filter_by(nombre="Seccion Prueba A").first() + assert seccion1.indice == 2 + seccion2 = CursoSeccion.query.filter_by(nombre="Seccion Prueba B").first() + assert seccion2.indice == 1 + seccion3 = CursoSeccion.query.filter_by(nombre="Seccion Prueba C").first() + assert seccion3.indice == 3 + modificar_indice_curso(codigo_curso="demo", indice=3, task="decrement") + seccion1 = CursoSeccion.query.filter_by(nombre="Seccion Prueba A").first() + assert seccion1.indice == 3 + seccion2 = CursoSeccion.query.filter_by(nombre="Seccion Prueba B").first() + assert seccion2.indice == 1 + seccion3 = CursoSeccion.query.filter_by(nombre="Seccion Prueba C").first() + assert seccion3.indice == 2 + modificar_indice_curso(codigo_curso="demo", indice=2, task="increment") + seccion1 = CursoSeccion.query.filter_by(nombre="Seccion Prueba A").first() + assert seccion1.indice == 2 + seccion2 = CursoSeccion.query.filter_by(nombre="Seccion Prueba B").first() + assert seccion2.indice == 1 + seccion3 = CursoSeccion.query.filter_by(nombre="Seccion Prueba C").first() + assert seccion3.indice == 3 + # Eliminamos la seccion con indice 2 + seccion1 = CursoSeccion.query.filter_by(nombre="Seccion Prueba A").delete() + database.session.commit() + reorganiza_indice_curso(codigo_curso="demo") + seccion1 = CursoSeccion.query.filter_by(nombre="Seccion Prueba B").first() + assert seccion1.indice == 1 + seccion2 = CursoSeccion.query.filter_by(nombre="Seccion Prueba C").first() + assert seccion2.indice == 2 + seccion3 = CursoSeccion.query.filter_by(indice=3).first() + assert seccion3 is None + cuenta = CursoSeccion.query.filter_by(curso="demo").count() + assert cuenta == 2 + + +def test_reorganizar_indice_web(client, auth): + from now_lms import CursoSeccion + + auth.login() + # Crear un curso. + post = client.post( + "/new_curse", + data={ + "nombre": "Curso de Prueba", + "codigo": "T-002", + "descripcion": "Curso de Prueba.", + }, + ) + curso = now_lms.Curso.query.filter_by(codigo="T-002").first() + assert curso.nombre == "Curso de Prueba" + assert curso.descripcion == "Curso de Prueba." + # Crear una sección del curso. + post1 = client.post( + "/course/T-002/new_seccion", + data={"nombre": "Seccion test 2", "descripcion": "Seccion test 2."}, + ) + post2 = client.post( + "/course/T-002/new_seccion", + data={"nombre": "Seccion test 3", "descripcion": "Seccion test 3."}, + ) + post3 = client.post( + "/course/T-002/new_seccion", + data={"nombre": "Seccion test 1", "descripcion": "Seccion test 1."}, + ) + cuenta = now_lms.CursoSeccion.query.filter_by(curso="T-002").count() + assert cuenta == 3 + seccion1 = CursoSeccion.query.filter(CursoSeccion.nombre == "Seccion test 1", CursoSeccion.curso == "T-002").first() + assert seccion1.indice == 3 + seccion2 = CursoSeccion.query.filter(CursoSeccion.nombre == "Seccion test 2", CursoSeccion.curso == "T-002").first() + assert seccion2.indice == 1 + seccion3 = CursoSeccion.query.filter(CursoSeccion.nombre == "Seccion test 3", CursoSeccion.curso == "T-002").first() + assert seccion3.indice == 2 + client.get("/course/seccion/T-002/increment/3") + seccion1 = CursoSeccion.query.filter(CursoSeccion.nombre == "Seccion test 1", CursoSeccion.curso == "T-002").first() + assert seccion1.indice == 3 + seccion2 = CursoSeccion.query.filter(CursoSeccion.nombre == "Seccion test 2", CursoSeccion.curso == "T-002").first() + assert seccion2.indice == 1 + seccion3 = CursoSeccion.query.filter(CursoSeccion.nombre == "Seccion test 3", CursoSeccion.curso == "T-002").first() + assert seccion3.indice == 2 + client.get("/course/seccion/T-002/decrement/2") + seccion1 = CursoSeccion.query.filter(CursoSeccion.nombre == "Seccion test 1", CursoSeccion.curso == "T-002").first() + assert seccion1.indice == 3 diff --git a/yarn.lock b/yarn.lock index 464217b2..1ed07dbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,9 +3,9 @@ bootstrap-icons@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/bootstrap-icons/-/bootstrap-icons-1.5.0.tgz#2cb19da148aa9105cb3174de2963564982d3dc55" - integrity sha512-44feMc7DE1Ccpsas/1wioN8ewFJNquvi5FewA06wLnqct7CwMdGDVy41ieHaacogzDqLfG8nADIvMNp9e4bfbA== + version "1.7.2" + resolved "https://registry.yarnpkg.com/bootstrap-icons/-/bootstrap-icons-1.7.2.tgz#4024e081e2c850602552e1fed6451e682d09322a" + integrity sha512-NiR2PqC73AQOPdVSu6GJfnk+hN2z6powcistXk1JgPnKuoV2FSdSl26w931Oz9HYbKCcKUSB6ncZTYJAYJl3QQ== bootstrap@^5.1.1: version "5.1.3"