diff --git a/.gitignore b/.gitignore index 7364c5690..d12b36ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules public tmp +.vagrant +Vagrantfile +*.swp diff --git a/Dockerfile b/Dockerfile index e23ade909..f41022fd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,10 @@ FROM democracyos/democracyos:2.11.15 -MAINTAINER Matías Lescano +MAINTAINER Democracia en Red + +COPY ./dos-override/models/comment.js /usr/src/lib/models/comment.js +COPY ./dos-override/api-v2/db-api/comments/index.js /usr/src/lib/api-v2/db-api/comments/index.js +COPY ./dos-override/api-v2/db-api/comments/scopes.js /usr/src/lib/api-v2/db-api/comments/scopes.js ENV LOCALE=es \ AVAILABLE_LOCALES=es,en \ @@ -9,8 +13,8 @@ ENV LOCALE=es \ MULTI_FORUM=true \ RESTRICT_FORUM_CREATION=true \ FAVICON=/ext/lib/boot/favicon.ico \ - LOGO=https://consultapublica.blob.core.windows.net/assets/Logo_Presidencia.svg \ - LOGO_MOBILE=https://consultapublica.blob.core.windows.net/assets/Logo_Presidencia.svg \ + LOGO=/ext/lib/site/footer/logo-footer.svg \ + LOGO_MOBILE=/ext/lib/site/footer/logo-footer.svg \ NOTIFICATIONS_MAILER_EMAIL=gobiernoabierto@modernizacion.gob.ar \ NOTIFICATIONS_MAILER_NAME='Consulta Pública Argentina' \ ORGANIZATION_EMAIL=gobiernoabierto@modernizacion.gob.ar \ diff --git a/README.md b/README.md index 24ba47351..0d227b81e 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,19 @@ -# Consulta Pública Argentina +![Consulta Pública Argentina](/docs/consulta-publica-header.png?raw=true "Consulta Pública Argentina") -Fork de la plataforma [DemocracyOS](https://github.com/DemocracyOS/democracyos) con modificaciones específicas para Gobierno Abierto de Argentina +# Consulta Pública + [![GitHub tag](https://img.shields.io/github/tag/datosgobar/consulta-publica.svg?style=flat-square)](https://GitHub.com/datosgobar/consulta-publica/tags) +[![GitHub forks](https://img.shields.io/github/forks/datosgobar/consulta-publica.svg?style=flat-square&label=Fork&maxAge=2592000)](https://GitHub.com/datosgobar/consulta-publica/network/) -## Para empezar +> La Plataforma de **Consulta Pública** es un desarrollo basado en la tecnología de [DemocracyOS](https://github.com/DemocracyOS/democracyos) coordinado por la *[Subsecretaría de Innovación Pública y Gobierno Abierto](https://www.argentina.gob.ar/modernizacion/gobiernoabierto)* pertenece al Ministerio de Modernización de Argentina. -1. Asegurate tener instalado [Docker 1.13.0+](https://www.docker.com/). -2. Cloná este repositorio. -3. Copiá el archivo `docker-compose.override.yml.example` a `docker-compose.override.yml`, y agregá tu mail en la variable `STAFF`. De este modo vas a poder administrar el contenido. (**Edit 10/01/2019**: Revisar el apartado "Nota") -4. Empezá el servidor con `docker-compose up --build` _(la primera vez puede llevar un ratito)_ -5. Navegá a [http://localhost:3000](http://localhost:3000) -6. Registrate, entrá, y podés empezar a crear contenido en http://localhost:3000/ajustes/administrar +El código se basa en [DemocracyOS](https://github.com/DemocracyOS/democracyos), plataforma de código abierto especialmente diseñada para informar, debatir y votar propuestas públicas de forma online hacia la construcción de una democracia adaptada al siglo XXI. -#### ⚠️ NOTA - Variables de entorno para Mi Argentina -De acuerdo a los ultimos cambios implementados en el sprint para agregar el login con Mi Argentina, se agregan las siguientes variables de entorno. **Importante de agregar al ENV del docker-compose** +## Indice -``` -CUSTOM_SIGNIN=true -OIDC_ISSUER= -OIDC_AUTH= -OIDC_TOKEN= -OIDC_USER= -OIDC_CLIENT_ID= -OIDC_CLIENT_SECRET= -OIDC_CALLBACK= -``` - -### Comandos - -``` -# Para abrir el server local -docker-compose up -``` - -``` -# Si cambiás alguna dependencia en el package.json, tenes que volver a buildear la imagen de Docker con: -docker-compose up --build -``` - -``` -# Para poder entrar al container de DemocracyOS: -docker exec -it dos bash -``` - -### Referencias - -* El archivo `docker-compose.override.yml` se encuentra en el `.gitignore` para estar seguros de no subir cualquier información sensible al repo, como keys, etc. -* Si querés saber más sobre `docker-compose`, acá está toda la documentación: https://docs.docker.com/compose/ -* En el archivo `docker-compose.override.yml` podes configurar DemocracyOS con cualquiera de las variables de entorno listadas acá: http://docs.democracyos.org/configuration.html -* El puerto `27017` está expuesto para que puedas administrar la base de datos con algún cliente de MongoDB, por ejemplo con [Robomongo](https://robomongo.org/). -* Todas las vistas personalizadas para Consulta Pública se encuentran en [`/ext`](ext). Siguiendo el mismo patrón de carpetas que [DemocracyOS/democracyos](https://github.com/DemocracyOS/democracyos). - -## Corriendo en Producción - -Usar de referencia el repositorio [DemocracyOS/onpremises](https://github.com/DemocracyOS/onpremises). Utiliza Ansible para el aprovisionamiento, y Docker Compose para correr el servidor. - -### Imagen de Docker - -La imagen se encuentra en: https://hub.docker.com/r/datosgobar/democracyos/ - -Para buildear la imagen: -* `docker build . -t datosgobar/democracyos:latest` - -Para subir la imagen: -* `docker push datosgobar/democracyos:latest` - -Todo junto: -``` -docker build . -t datosgobar/democracyos:latest && docker push datosgobar/democracyos:latest -``` +- [Development](/docs/development.md) +- [Consejos para el desarrollo](/docs/consejos-dev.md) +- [Personalización de la plataforma](/docs/personalizacion.md) +- [Deployment](/deployment/README.md) +- [Manual instructivo para administradores](/docs/manual-admin.md) +- [Manual instructivo para usuarios](/docs/manual-usuarios.md) \ No newline at end of file diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 000000000..e2c6bf099 --- /dev/null +++ b/deployment/README.md @@ -0,0 +1,191 @@ +# Deployment + +Esta guía presenta los pasos necesarios para poder hacer un deployment de consulta pública en plataformas basadas en GNU/Linux. La herramienta de aprovisionamiento seleccionada es [Ansible](https://www.ansible.com). Todos estos pasos se ejecutan desde el equipo del administrador. _Work in progress_. + +## Pre-requisitos + +**Imágen de Docker** + +El sistema de consulta pública se distribuye e instala generando una imágen de [Docker](https://www.docker.com). Es necesario que al momento de seguir esta guía el usuario cuente con una imágen disponible en el [registro oficial de Docker (Docker Hub)](https://hub.docker.com). Los pasos necesarios escapan al foco de esta guía pero pueden resumirse como: +* Crear una cuenta en [Docker Hub](https://hub.docker.com). +* Crear un repositorio en [Docker Hub](https://hub.docker.com). +* Pushear la imágen desde el entorno del desarrollador. + +## Requisitos + +**Acceso SSH** + +Los distintos _playbooks de Ansible_ requieren acceso por SSH al servidor de destino. El mismo debe realizarse mediante la utilización de llaves asimétricas. Estos pasos se explican en [esta completa guía](https://www.digitalocean.com/community/tutorials/como-configurar-las-llaves-ssh-en-ubuntu-18-04-es). Una excelente práctica es utilizar el fichero de configuración del cliente ssh ubicado en `~/.ssh/config`, esto se explica en [esta completa guía (inglés)](https://www.digitalocean.com/community/tutorials/how-to-configure-custom-connection-options-for-your-ssh-client). + +**Ansible** + +Ansible es una herramienta de aprovisionamiento que permite aplicar planes de ejecución llamados _playbooks_ en los cuales se definen una serie de pasos a realizar en uno o más hosts. En este caso utilizamos Ansible para definir la instalación de la plataforma de consulta pública, formalizando y automatizando el proceso. La guía oficial de instalación se encuentra [aquí](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) aunque es muy probable que se encuentre ya disponible en los repositorios oficiales de las distintas distribuciones: + +* Versión mínima requerida: 2.3. + +```bash +# Ubuntu +$ sudo apt-get install ansible +# RHEL/CentOS/Fedora yum +$ sudo yum install ansible +# RHEL/CentOS/Fedora dnf +$ sudo dnf install ansible +# macOS con brew +$ brew install ansible +# Python pip +$ pip install ansible +``` + +**Inventario de Ansible** + +Ansible utiliza un inventario donde es necesario definir los hosts sobre los cuales va a trabajar. La ubicación por defecto del mismo es `/etc/ansible/inventory`, a modo de ejemplo se muestra como configurar el inventario y a su vez `~/.ssh/config`: + +```bash +## Configuración del cliente SSH y el inventario de Ansible sin SSH alias +# Contenido de ~/.ssh/config +Host mi.server.local + HostName mi.server.local + User ubuntu + Port 22 + IdentityFile ~/.ssh/llave + +# Contenido de /etc/ansible/inventory +[servidores] +mi-server ansible_ssh_host=mi.server.local ansible_connection=ssh ansible_port=22 ansible_user=ubuntu +``` + +```bash +## Configuración del cliente SSH y el inventario de Ansible con SSH alias +# Contenido de ~/.ssh/config +Host serveralias + HostName mi.server.local + User ubuntu + Port 22 + IdentityFile ~/.ssh/llave + +# Contenido de /etc/ansible/inventory +[servidores] +mi-server ansible_ssh_host=serveralias ansible_connection=ssh ansible_port=22 ansible_user=ubuntu +``` + +## Plataformas Soportadas + +El deployment está basado en Docker y docker-compose. Estos playbooks fueron probados con [Vagrant](https://www.vagrantup.com) utilizando boxes oficiales: +* CentOS (7.3, 7.4, 7.5). +* Debian (8 y 9). +* Ubuntu (16.04 LTS y 18.04 LTS, 14.04 ya no tiene soporte). + +## Playbooks + +IMPORTANTE: muchas configuraciones requieren permisos de superusuario, en caso de que el usuario configurado en el inventario de Ansible requiera de su password para utilizar `sudo` es necesario agregar a cada ejecución de `ansible-playbook` el flag `--ask-become-pass` al final de cada línea, ejemplo: + +```bash +# ansible mostrará un prompt para ingresar el password de sudoer +$ ansible-playbook instalacion_docker.yaml --extra-vars "host_destino=mi-server" --ask-become-pass +``` + +### Instalación de Docker + +La primera tarea a realizar es la instalación de Docker en el servidor de destino. Esto se realiza con el playbook `instalacion_docker.yaml` de la siguiente forma: + +```bash +# mi-server es el nombre que aparece en el inventario de Ansible +$ ansible-playbook instalacion_docker.yaml --extra-vars "host_destino=mi-server" +``` + +### Instalación de la plataforma + +La instalación de la plataforma se realiza con el segundo playbook, `instalacion_plataforma.yaml`. En este caso, primero es necesario completar el fichero con variables incluido como `variables.yaml`. Es necesario aclarar que el deployment requiere de un hostname al cual responderá la aplicación, en caso de no contar con resolución DNS es posible forzar dicha resolución en el fichero de hosts del sistema operativo del administrador. + +La información sobre las posibles variables y sus valores puede obtenerse en el fichero `variables.yaml` de este repositorio. + +**Funcionamiento del deployment** + +La aplicación se instala en formato de docker-compose, por defecto se levantan tres contenedores: +* [Traefik](https://traefik.io): es un proxy/balanceador de carga orientado a entornos cloud. Es el que realiza la terminación TLS cuando se utiliza HTTPS y hace las veces de proxy reverso contra la aplicación. +* Aplicación de Consulta Pública: la aplicación indicada en la variable `IMAGE` de `variables.yaml`. +* MongoDB: en caso de no utilizar una base de datos externa, el deployment levanta una instancia de MongoDB 3.2 en un contenedor. + +Tanto el docker-compose como las configuraciones y archivos de MongoDB se almacenan como volúmenes de Docker en el path de instalación, una instalación por defecto se ve de la siguiente forma: + +```bash +$ cd /opt/consulta-publica && tree +. +├── docker-compose.yaml +├── volumenes +    ├── traefik +    └── mongo +``` + +#### Aplicación por HTTP + +En este tipo de instalación la plataforma funciona por HTTP, la configuración básica es la siguiente: + +```yaml +# variables.yaml +deploy: + hostname: consulta.ejemplo.org + +docker: + IMAGE: democraciaenred/consulta-publica:development + # Resto de variables... +``` + +Luego se ejecuta el playbook: + +```bash +# mi-server es el nombre que aparece en el inventario de Ansible +$ ansible-playbook instalacion_plataforma.yaml --extra-vars "host_destino=mi-server" +``` + +En este caso, la aplicación estará disponible en http://consulta.ejemplo.org. + +#### Aplicación por HTTPS con certificado propio + +En este tipo de instalación la plataforma funciona por HTTPS con certificados propios, es necesario completar el fichero de variables con las rutas absolutas hacia el certificado y la llave privada: + +```yaml +# variables.yaml +deploy: + hostname: consulta.ejemplo.org + protocolo: https + https_path_certificado: /home/ubuntu/server.crt + https_path_llave: /home/ubuntu/server.key + +docker: + IMAGE: democraciaenred/consulta-publica:development + # Resto de variables... +``` + +Luego se ejecuta el playbook: + +```bash +# mi-server es el nombre que aparece en el inventario de Ansible +$ ansible-playbook instalacion_plataforma.yaml --extra-vars "host_destino=mi-server" +``` + +En este caso, la aplicación estará disponible en https://consulta.ejemplo.org. + +#### Aplicación por HTTPS con Let's Encrypt (Staging) + +En este tipo de instalación se utiliza el servicio gratuito de Let's Encrypt para obtener un certificado y llave, es necesario completar el fichero de variables con una dirección de correo electrónico del responsable del dominio. Es importante aclarar que a menos que se trate de un entorno productivo es mejor utilizar los servidores de staging de Let's Encrypt tal como se muestra a continuación: + +```yaml +# variables.yaml +deploy: + hostname: consulta.ejemplo.org + protocolo: https + https_lets_encrypt_email: admin@consulta.ejemplo.org + https_lets_encrypt_staging: true + +docker: + IMAGE: democraciaenred/consulta-publica:development + # Resto de variables... +``` + +```bash +# mi-server es el nombre que aparece en el inventario de Ansible +$ ansible-playbook instalacion_plataforma.yaml --extra-vars "host_destino=mi-server" +``` + +En este caso, la aplicación estará disponible en http://consulta.ejemplo.org. diff --git a/deployment/instalacion_docker.yaml b/deployment/instalacion_docker.yaml new file mode 100644 index 000000000..61612fc2c --- /dev/null +++ b/deployment/instalacion_docker.yaml @@ -0,0 +1,147 @@ +- hosts: "{{ host_destino }}" + # Python para Ubuntu + gather_facts: false + + pre_tasks: + - raw: 'sudo apt -y update && sudo apt install -y python-minimal' + ignore_errors: true + - setup: + + tasks: + # Documentacion Oficial: https://docs.docker.com/install/linux/docker-ce/debian/ + - name: Instalacion en Debian + block: + - name: Instalar dependencias + apt: + name: "{{ item }}" + state: present + update_cache: true + with_items: + - apt-transport-https + - ca-certificates + - curl + - gnupg2 + - software-properties-common + + - name: Instalar llave PGP de Docker + apt_key: + url: "https://download.docker.com/linux/debian/gpg" + state: present + + - name: Agregar repositorio de Docker + apt_repository: + repo: "deb [arch=amd64] https://download.docker.com/linux/debian {{ ansible_distribution_release }} stable" + state: present + when: ansible_distribution_release == 'jessie' + + - name: Instalar Docker CE + apt: + name: docker-ce + state: present + update_cache: true + + become: true + when: ansible_distribution == 'Debian' + + # Documentacion Oficial: https://docs.docker.com/install/linux/docker-ce/ubuntu/ + - name: Instalacion en Ubuntu + block: + - name: Instalar dependencias + apt: + name: "{{ item }}" + state: present + update_cache: true + with_items: + - apt-transport-https + - ca-certificates + - curl + - software-properties-common + + - name: Instalar llave PGP de Docker + apt_key: + url: "https://download.docker.com/linux/ubuntu/gpg" + state: present + + - name: Agregar repositorio de Docker + apt_repository: + repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" + state: present + + - name: Instalar Docker CE + apt: + name: docker-ce + state: present + update_cache: true + + become: true + when: ansible_distribution == 'Ubuntu' + + # Documentacion Oficial: https://docs.docker.com/install/linux/docker-ce/centos/ + - name: Instalacion en RHEL/CentOS + block: + # Se deshabilita SELINUX para facilitar el acceso. + # La configuracion de SELINUX queda en mano de los administradores. + - name: Disable SELINUX + selinux: + state: disabled + register: selinux_state + + - name: Reboot + shell: "sleep 2 && shutdown -r now" + async: 1 + poll: 0 + when: selinux_state.reboot_required + + - name: Esperar que vuelva el host + wait_for_connection: + timeout: 60 + delay: 5 + sleep: 2 + when: selinux_state.reboot_required + + - name: Instalar dependencias + yum: + name: "{{ item }}" + state: present + update_cache: true + with_items: + - yum-utils + - device-mapper-persistent-data + - lvm2 + + - name: Agregar repositorio de Docker + get_url: + url: "https://download.docker.com/linux/centos/docker-ce.repo" + dest: /etc/yum.repos.d/docker-ce.repo + + - name: Instalar Docker CE + yum: + name: docker-ce + state: present + update_cache: true + + become: true + when: ansible_os_family == 'RedHat' + + - name: Iniciar Docker CE + systemd: + name: docker + state: started + enabled: true + become: true + + - name: Instalar docker-compose + get_url: + url: "https://github.com/docker/compose/releases/download/1.24.0/docker-compose-Linux-x86_64" + dest: /usr/local/bin/docker-compose + mode: 0755 + become: true + when: ansible_os_family != 'RedHat' + + - name: Instalar docker-compose + get_url: + url: "https://github.com/docker/compose/releases/download/1.24.0/docker-compose-Linux-x86_64" + dest: /usr/bin/docker-compose + mode: 0755 + become: true + when: ansible_os_family == 'RedHat' diff --git a/deployment/instalacion_plataforma.yaml b/deployment/instalacion_plataforma.yaml new file mode 100644 index 000000000..24903a6dd --- /dev/null +++ b/deployment/instalacion_plataforma.yaml @@ -0,0 +1,152 @@ +- hosts: "{{ host_destino }}" + tasks: + - name: Incluir variables + include_vars: + file: variables.yaml + + # No funciona en macOS + - name: JWT + set_fact: + jwt_secret: "{{ 'session_secret' | password_hash('sha512', 65534 | random(seed=inventory_hostname) | string) | regex_replace('\\$','$$') }}" + + - name: Default para protocolo + set_fact: + protocolo: 'http' + when: deploy['protocolo'] == None + + - name: Fact para protocolo + set_fact: + protocolo: "{{ deploy['protocolo'] }}" + when: deploy['protocolo'] != None + + - name: Utilizar certificado propio + set_fact: + certificado_propio: true + protocolo: 'https' + when: deploy['https_path_certificado'] != None and deploy['https_path_llave'] != None + + - name: No utilizar certificado propio + set_fact: + certificado_propio: false + when: deploy['https_path_certificado'] == None or deploy['https_path_llave'] == None + + - name: Utilizar Let's Encrypt + set_fact: + lets_encrypt: true + protocolo: 'https' + when: deploy['https_lets_encrypt_email'] != None + + - name: No utilizar Let's Encrypt + set_fact: + lets_encrypt: false + when: deploy['https_lets_encrypt_email'] == None + + - name: Default para Let's Encrypt Staging + set_fact: + lets_encrypt_staging: true + when: deploy['https_lets_encrypt_staging'] == None + + - name: Valor para Let's Encrypt Staging + set_fact: + lets_encrypt_staging: "{{ deploy['https_lets_encrypt_staging'] }}" + protocolo: 'https' + when: deploy['https_lets_encrypt_staging'] != None + + - name: Default para mongo externo + set_fact: + mongo_externo: false + when: deploy['mongo_externo'] == None + + - name: Default para directorio de instalacion + set_fact: + path_instalacion: /opt/consulta-publica + when: deploy['path_instalacion'] == None + + - name: Crear directorio de instalacion + file: + path: "{{ path_instalacion }}" + state: directory + mode: 0755 + become: true + + - name: Fact para directorio de volumenes de Docker + set_fact: + path_volumenes: "{{ path_instalacion }}/volumenes" + + - name: Fact para directorio de Traefik + set_fact: + path_traefik: "{{ path_volumenes }}/traefik" + + - name: Fact para directorio de MongoDB + set_fact: + path_mongo: "{{ path_volumenes }}/mongodb" + when: not mongo_externo + + - name: Crear directorio para volumenes de Docker + file: + path: "{{ path_volumenes }}" + state: directory + mode: 0755 + become: true + + - name: Crear directorio para Traefik + file: + path: "{{ path_traefik }}" + state: directory + mode: 0755 + become: true + + - name: Crear directorio para MongoDB + file: + path: "{{ path_mongo }}" + state: directory + mode: 0755 + become: true + when: not mongo_externo + + - name: Copiar traefik.toml + template: + src: templates/traefik.toml.j2 + dest: "{{ path_traefik }}/traefik.toml" + mode: 0644 + become: true + + - name: Touch ficheros de Traefik + file: + path: "{{ path_traefik }}/{{ item }}" + state: touch + mode: 0600 + with_items: + - acme.json + - traefik.log + become: true + + - name: Copiar certificado HTTPS + copy: + src: "{{ deploy['https_path_certificado'] }}" + dest: "{{ path_traefik }}/cert.crt" + mode: 0600 + when: certificado_propio + become: true + + - name: Copiar llave HTTPS + copy: + src: "{{ deploy['https_path_llave'] }}" + dest: "{{ path_traefik }}/key.key" + mode: 0600 + when: certificado_propio + become: true + + - name: Copiar docker-compose.yaml + template: + src: templates/docker-compose.yaml.j2 + dest: "{{ path_instalacion }}/docker-compose.yml" + become: true + + - name: Pull contenedores + shell: "docker-compose -f {{ path_instalacion }}/docker-compose.yml pull" + become: true + + - name: Docker Compose up + shell: "docker-compose -f {{ path_instalacion }}/docker-compose.yml up -d" + become: true diff --git a/deployment/templates/docker-compose.yaml.j2 b/deployment/templates/docker-compose.yaml.j2 new file mode 100644 index 000000000..858022578 --- /dev/null +++ b/deployment/templates/docker-compose.yaml.j2 @@ -0,0 +1,145 @@ +version: '3' + +services: + proxy: + image: traefik:1.6.6 + command: --api --docker + networks: + - frontend + - backend + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - {{ path_traefik }}/traefik.toml:/traefik.toml + - {{ path_traefik }}/traefik.log:/var/log/traefik.log +{% if protocolo == 'https' %} + - {{ path_traefik }}/acme.json:/acme.json +{% if certificado_propio %} + - {{ path_traefik }}/cert.crt:/cert.crt + - {{ path_traefik }}/key.key:/key.key +{% endif %} +{% endif %} + ports: + - "80:80" + - "127.0.0.1:8000:8000" + - "127.0.0.1:8080:8080" +{% if protocolo == 'https' %} + - "443:443" +{% endif %} + labels: + - "traefik.enable=false" + +{% if not mongo_externo %} + mongo: + image: mongo:3.2 + environment: + - MONGO_INITDB_DATABASE=consulta-publica + networks: + - backend + volumes: + - {{ path_mongo }}:/data/db + labels: + - "traefik.enable=false" +{% endif %} + + app: + image: {{ docker['IMAGE'] }} + networks: + - backend + environment: +{% if mongo_externo %} + - MONGO_URL={{ deploy['mongo_url'] }} +{% else %} + - MONGO_URL=mongodb://mongo/consulta-publica +{% endif %} + - JWT_SECRET={{ jwt_secret }} +{% if protocolo == 'https' %} + - HTTPS_REDIRECT=reverse-proxy + - SSL_REQUIRED=external +{% endif %} + - NODE_ENV=production +{% if docker['DEBUG'] == None %} + - DEBUG={{ docker['DEBUG'] }} +{% else %} + - DEBUG=democracyos* +{% endif %} +{% if docker['STAFF'] != None %} + - STAFF={{ docker['STAFF'] }} +{% endif %} +{% if docker['ORGANIZATION_EMAIL'] != None %} + - ORGANIZATION_EMAIL={{ docker['ORGANIZATION_EMAIL'] }} +{% endif %} +{% if docker['ORGANIZATION_NAME'] != None %} + - ORGANIZATION_NAME={{ docker['ORGANIZATION_NAME'] }} +{% endif %} +{% if docker['SOCIALSHARE_SITE_NAME'] != None %} + - SOCIALSHARE_SITE_NAME={{ docker['SOCIALSHARE_SITE_NAME'] }} +{% endif %} +{% if docker['SOCIALSHARE_SITE_DESCRIPTION'] != None %} + - SOCIALSHARE_SITE_DESCRIPTION={{ docker['SOCIALSHARE_SITE_DESCRIPTION'] }} +{% endif %} +{% if docker['SOCIALSHARE_IMAGE'] != None %} + - SOCIALSHARE_IMAGE={{ docker['SOCIALSHARE_IMAGE'] }} +{% endif %} +{% if docker['SOCIALSHARE_TWITTER_USERNAME'] != None %} + - SOCIALSHARE_TWITTER_USERNAME={{ docker['SOCIALSHARE_TWITTER_USERNAME'] }} +{% endif %} +{% if docker['TWEET_TEXT'] != None %} + - TWEET_TEXT={{ docker['TWEET_TEXT'] }} +{% endif %} +{% if docker['NOTIFICATIONS_MAILER_EMAIL'] != None %} + - NOTIFICATIONS_MAILER_EMAIL={{ docker['NOTIFICATIONS_MAILER_EMAIL'] }} +{% endif %} +{% if docker['NOTIFICATIONS_MAILER_NAME'] != None %} + - NOTIFICATIONS_MAILER_NAME={{ docker['NOTIFICATIONS_MAILER_NAME'] }} +{% endif %} +{% if docker['NOTIFICATIONS_NODEMAILER'] != None %} + - NOTIFICATIONS_NODEMAILER={{ docker['NOTIFICATIONS_NODEMAILER'] }} +{% endif %} +{% if docker['CUSTOM_SIGNIN'] != None %} + {% if docker['CUSTOM_SIGNIN'] %} + - CUSTOM_SIGNIN=true + {% if docker['OIDC_ISSUER'] != None %} + - OIDC_ISSUER={{ docker['OIDC_ISSUER'] }} + {% endif %} + {% if docker['OIDC_AUTH'] != None %} + - OIDC_AUTH={{ docker['OIDC_AUTH'] }} + {% endif %} + {% if docker['OIDC_TOKEN'] != None %} + - OIDC_TOKEN={{ docker['OIDC_TOKEN'] }} + {% endif %} + {% if docker['OIDC_USER'] != None %} + - OIDC_USER={{ docker['OIDC_USER'] }} + {% endif %} + {% if docker['OIDC_CLIENT_ID'] != None %} + - OIDC_CLIENT_ID={{ docker['OIDC_CLIENT_ID'] }} + {% endif %} + {% if docker['OIDC_CLIENT_SECRET'] != None %} + - OIDC_CLIENT_SECRET={{ docker['OIDC_CLIENT_SECRET'] }} + {% endif %} + {% if docker['OIDC_CALLBACK'] != None %} + - OIDC_CALLBACK={{ docker['OIDC_CALLBACK'] }} + {% endif %} + {% endif %} +{% endif %} + + labels: + - "traefik.enable=true" + - "traefik.backend=app" + - "traefik.port=3000" + - "traefik.frontend.rule=Host:{{ deploy['hostname'] }}" +{% if protocolo == 'http' %} + - "traefik.frontend.entryPoints=http" +{% endif %} +{% if protocolo == 'https' %} + - "traefik.frontend.entryPoints=http,https" + - "traefik.frontend.redirect.entryPoint=https" +{% endif %} + - "traefik.docker.network=backend" + restart: always + +networks: + frontend: + driver: bridge + backend: + driver: bridge + \ No newline at end of file diff --git a/deployment/templates/traefik.toml.j2 b/deployment/templates/traefik.toml.j2 new file mode 100644 index 000000000..7cad9cb3e --- /dev/null +++ b/deployment/templates/traefik.toml.j2 @@ -0,0 +1,45 @@ +{% if protocolo == 'http' %} +defaultEntryPoints = ["http"] +{% endif %} +{% if protocolo == 'https' %} +defaultEntryPoints = ["http", "https"] +{% endif %} + +[entryPoints] + [entryPoints.http] + address = ":80" +{% if protocolo == 'https' %} + [entyrPoints.http.redirect] + entryPoint = "https" + [entryPoints.https] + address = ":443" +{% if certificado_propio %} + [entryPoinst.https.tls] + [[entryPoints.https.tls.certificates]] + certFile = "/cert.crt" + keyFile = "/key.key" +{% endif %} +{% if lets_encrypt %} + [entryPoints.https.tls] + +[acme] +email = "{{ deploy['https_lets_encrypt_email'] }}" +storage = "/acme.json" +entryPoint = "https" +acmeLogging = true +onHostRule = true +{% if lets_encrypt_staging %} +caServer = "https://acme-staging-v02.api.letsencrypt.org/directory" +{% endif %} +[acme.httpChallenge] +entryPoint = "http" +{% endif %} +{% endif %} + +[docker] +endpoint = "unix:///var/run/docker.sock" +domain = "{{ deploy['hostname'] }}" + +logLevel = "INFO" +[traefikLog] + filePath = "/var/log/traefik.log" \ No newline at end of file diff --git a/deployment/variables.yaml b/deployment/variables.yaml new file mode 100644 index 000000000..aee89483c --- /dev/null +++ b/deployment/variables.yaml @@ -0,0 +1,59 @@ +deploy: + # Indica el protocolo a utilizar, por defecto es http, valores posibles: http, https. + protocolo: + + # Hostname para utilizar en la aplicación, obligatorio. + hostname: + + # Se utiliza para indicar que la base de datos de Mongo es externa al deploy, por defecto es false, valores posibles: true, false. + mongo_externo: + + # En caso de que mongo_externo = true es necesario indicar la URL para el servidor de MongoDB externo. + mongo_url: + + # Donde instalar el deplot, por defecto es /opt/consulta-publica. + path_instalacion: + + ## Utilización de certificado propio: + ## Cuando https_path_certificado y https_path_llave están definidos, el protocolo se configura como HTTPS automáticamente. + # Ruta absoluta hacia el fichero del certificado HTTPS. + https_path_certificado: + # Ruta absoluta hacia el fichero de la llave privada HTTPS. + https_path_llave: + + ## Utilización de Let's Encrypt: + ## Cuando https_lets_encrypt_email está definido el protocolo se configura como HTTPS automáticamente. + # Correo electrónico del administrador del dominio a utilizar con Let's Encrypt + https_lets_encrypt_email: + # Se utiliza para indicar el uso del entorno staging de Let's Encrypt. Por defecto es true, valores posibles: true, false. + https_lets_encrypt_staging: + + +docker: + # Imagen de docker a utilizar, obligatorio. + IMAGE: + + # Variables de DemocracyOS, ver sección development. + DEBUG: + STAFF: + ORGANIZATION_EMAIL: + ORGANIZATION_NAME: + SOCIALSHARE_SITE_NAME: + SOCIALSHARE_SITE_DESCRIPTION: + SOCIALSHARE_IMAGE: + SOCIALSHARE_DOMAIN: + SOCIALSHARE_TWITTER_USERNAME: + TWEET_TEXT: + NOTIFICATIONS_MAILER_EMAIL: + NOTIFICATIONS_MAILER_NAME: + NOTIFICATIONS_NODEMAILER: + + # Las siguientes variables solo se tienen en cuenta si CUSTOM_SIGNIN = true + CUSTOM_SIGNIN: + OIDC_ISSUER: + OIDC_AUTH: + OIDC_TOKEN: + OIDC_USER: + OIDC_CLIENT_ID: + OIDC_CLIENT_SECRET: + OIDC_CALLBACK: diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example deleted file mode 100644 index 9189b8220..000000000 --- a/docker-compose.override.yml.example +++ /dev/null @@ -1,6 +0,0 @@ -version: '3' - -services: - app: - environment: - - STAFF=matias@democracyos.org diff --git a/docker-compose.yml b/docker-compose.yml index fcf3b064c..7549fa3a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,4 +24,4 @@ services: ports: - 27017:27017 volumes: - - ./tmp/db:/data/db +- ./tmp/db:/data/db \ No newline at end of file diff --git a/docker-compose.yml.example b/docker-compose.yml.example new file mode 100644 index 000000000..1e1c1faf6 --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,65 @@ +version: '3' + +services: + app: + container_name: miconsultapublica + build: . + command: ["./node_modules/.bin/gulp", "bws"] + environment: + - NODE_ENV=development + - DEBUG=democracyos* + - MONGO_URL=mongodb://mongo/mi-consultapublica + # Importante: Defina el "Staff" de administradores para que en su registro el sistema le de privilegios de admin + # Para un solo admin: + # - STAFF=hola@miemail.com + # Para varios admins: + # - STAFF=hola@miemail.com,usuario@otroemail.com,otrousuario@nuevoemail.com + - STAFF=hola@miemail.com + # Logos + LOGO=/ext/lib/site/home-multiforum/logo-header.svg \ + LOGO_MOBILE=/ext/lib/site/home-multiforum/logo-header.svg \ + # Organizacion + - ORGANIZATION_EMAIL=miconsultapublica@midominio.com.ar + - ORGANIZATION_NAME="Mi Consulta Pública" + # Social media y email settings + - SOCIALSHARE_SITE_NAME="Mi Consulta Pública" + - SOCIALSHARE_SITE_DESCRIPTION="Plataforma de participación ciudadana" + - SOCIALSHARE_IMAGE=https://urlexterno.com/mi-imagen-externa.png #Cambiar + - SOCIALSHARE_DOMAIN=miconsultapublica.midominio.com #Cambiar + - SOCIALSHARE_TWITTER_USERNAME=@miConsultaPublica #Cambiar + - TWEET_TEXT="Estoy tratando de mejorar esta propuesta “{topic.mediaTitle}” ¡Participá vos también!" + # Configuracion del mailer + - NOTIFICATIONS_MAILER_EMAIL=miconsultapublica@midominio.com + - NOTIFICATIONS_MAILER_NAME="Mi consulta ṕública" + - NOTIFICATIONS_NODEMAILER={"host:"xxxxx.smtp.com","port":465,"secure":true,"auth":{"user":"xxxxxxxx","pass":"xxxxxxx"}} #Cambiar + # Requerido: Genere un token para JWT + - JWT_SECRET= #Cambiar + # Si desea activar Mi Argentina, descomente los siguientes puntos + # - CUSTOM_SIGNIN=true + # - OIDC_ISSUER= #Cambiar + # - OIDC_AUTH= #Cambiar + # - OIDC_TOKEN= #Cambiar + # - OIDC_USER= #Cambiar + # - OIDC_CLIENT_ID= #Cambiar + # - OIDC_CLIENT_SECRET= #Cambiar + # - OIDC_CALLBACK= #Cambiar + links: + - mongo + ports: + - 3000:3000 + volumes: + - ./ext/lib:/usr/src/ext/lib + - ./public:/usr/src/public + # Forced overrides of DemocracyOs + - ./dos-override/models/comment.js:/usr/src/lib/models/comment.js + - ./dos-override/api-v2/db-api/comments/index.js:/usr/src/lib/api-v2/db-api/comments/index.js + - ./dos-override/api-v2/db-api/comments/scopes.js:/usr/src/lib/api-v2/db-api/comments/scopes.js + tty: true + + mongo: + container_name: miconsultapublica-mongo + image: mongo:3.2 + ports: + - 27017:27017 + volumes: + - ./tmp/db:/data/db \ No newline at end of file diff --git a/docs/admin-manual/image1.png b/docs/admin-manual/image1.png new file mode 100644 index 000000000..928256928 Binary files /dev/null and b/docs/admin-manual/image1.png differ diff --git a/docs/admin-manual/image10.png b/docs/admin-manual/image10.png new file mode 100644 index 000000000..ef0207b8f Binary files /dev/null and b/docs/admin-manual/image10.png differ diff --git a/docs/admin-manual/image11.png b/docs/admin-manual/image11.png new file mode 100644 index 000000000..ee608b43d Binary files /dev/null and b/docs/admin-manual/image11.png differ diff --git a/docs/admin-manual/image12.png b/docs/admin-manual/image12.png new file mode 100644 index 000000000..8d7c10559 Binary files /dev/null and b/docs/admin-manual/image12.png differ diff --git a/docs/admin-manual/image13.png b/docs/admin-manual/image13.png new file mode 100644 index 000000000..31eddd740 Binary files /dev/null and b/docs/admin-manual/image13.png differ diff --git a/docs/admin-manual/image14.png b/docs/admin-manual/image14.png new file mode 100644 index 000000000..33cb74042 Binary files /dev/null and b/docs/admin-manual/image14.png differ diff --git a/docs/admin-manual/image15.png b/docs/admin-manual/image15.png new file mode 100644 index 000000000..abff54d66 Binary files /dev/null and b/docs/admin-manual/image15.png differ diff --git a/docs/admin-manual/image2.png b/docs/admin-manual/image2.png new file mode 100644 index 000000000..bb51b82fc Binary files /dev/null and b/docs/admin-manual/image2.png differ diff --git a/docs/admin-manual/image3.png b/docs/admin-manual/image3.png new file mode 100644 index 000000000..a55de1ee3 Binary files /dev/null and b/docs/admin-manual/image3.png differ diff --git a/docs/admin-manual/image4.png b/docs/admin-manual/image4.png new file mode 100644 index 000000000..dc27429d6 Binary files /dev/null and b/docs/admin-manual/image4.png differ diff --git a/docs/admin-manual/image5.png b/docs/admin-manual/image5.png new file mode 100644 index 000000000..ee4e84a83 Binary files /dev/null and b/docs/admin-manual/image5.png differ diff --git a/docs/admin-manual/image6.png b/docs/admin-manual/image6.png new file mode 100644 index 000000000..abff54d66 Binary files /dev/null and b/docs/admin-manual/image6.png differ diff --git a/docs/admin-manual/image7.png b/docs/admin-manual/image7.png new file mode 100644 index 000000000..8e8bbfff7 Binary files /dev/null and b/docs/admin-manual/image7.png differ diff --git a/docs/admin-manual/image8.png b/docs/admin-manual/image8.png new file mode 100644 index 000000000..816078688 Binary files /dev/null and b/docs/admin-manual/image8.png differ diff --git a/docs/admin-manual/image9.png b/docs/admin-manual/image9.png new file mode 100644 index 000000000..e6e14c96a Binary files /dev/null and b/docs/admin-manual/image9.png differ diff --git a/docs/consejos-dev.md b/docs/consejos-dev.md new file mode 100644 index 000000000..f1aacd6bc --- /dev/null +++ b/docs/consejos-dev.md @@ -0,0 +1,28 @@ +# Consejos para el desarrollo + +Al ser una "extension" de DemocracyOS, mucho del código viene por el lado de la plataforma base. Para eso, es recomendable tambien tener el codigo de DemocracyOS a mano por si existen api endpoints o vistas que necesite consultar. + +Esta extension agrega otros api endpoints y vistas que complementan a DemocracyOS y permiten la correcta operacion de la plataforma. + +Supuestamente todo lo que usted debe modificar (si es necesario) existira bajo la carpeta `/ext` + +Luego, siguiendo la estructura de DemocracyOS, se tiene: + +* Todo lo que sea el sitio web, bajo `/ext/site` +* Todo lo que sea el panel de admin, bajo `/ext/admin` +* Nuevas api endpoints *pueden* llegar a convivir en `/ext/api` +* Nuevas interfaces con la base de dato *pueden* llegar a convivir en `/ext/db-api` + +**NOTA:** Recomendamos tener mucho cuidado en la implementacion de funcionalidades complejas que impliquen el backend. Probablemente el interes se encuentre en la personalizacion del sitio web. + +#### Dependencias +- ExpressJS +- Mongoose +- React +- Style (css) + +#### Algunas consideraciones: + +- Si el servicio está levantado, el sitio puede buidearse on-demand debido al watch. O sea, todo cambio que haga en `/ext/site` tiene un watcher que mira cambios. No tiene hot-reload, debe recargar la pagina en cada cambio. +- A igual que el punto anterior, tambien todo CSS se buildea y cuenta con un watcher. Es necesario tambien recargar la pagina. +- Si hace cambios en la API, debe detener el servicio (`Ctrl + C`) y volver a levantarlo. No cuenta con un watcher para buildear el codigo. diff --git a/docs/consulta-publica-header.png b/docs/consulta-publica-header.png new file mode 100644 index 000000000..749d82d43 Binary files /dev/null and b/docs/consulta-publica-header.png differ diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 000000000..de58f9d51 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,176 @@ +# Development + +Consulta Publica requiere **Docker** y **Docker compose**. + +Una vez que verifique que cuenta con estas dependencias, haga un **FORK** y clone su nuevo repositorio. + +## Variables de entorno + +En primer lugar debemos adecuar el `docker-compose.yml` + +La aplicación utiliza [DemocracyOS 2.11.15](https://hub.docker.com/r/democracyos/democracyos) y requiere **Mongo 3.2** + +Es preferente trabajar en el entorno de desarrollo utilizando docker-compose donde definimos las variables de entorno y los servicios con la que la aplicación trabaja, en este caso, mongo 3.2 + +Recomendamos ver el siguiente `docker-compose.yml` a continuacion para usarlo como base donde podrá editarlo para dar marcha su instancia de desarrollo local. + +En el repositorio encontrará la siguiente plantilla en `docker-compose.yaml.example`. Pase el contenido a `docker-compose.yml` y utilice la pĺantilla como base. + + +```yaml +version: '3' + +services: + app: + container_name: miconsultapublica + build: . + command: ["./node_modules/.bin/gulp", "bws"] + environment: + - NODE_ENV=development + - DEBUG=democracyos* + - MONGO_URL=mongodb://mongo/mi-consultapublica + # Importante: Defina el "Staff" de administradores para que en su registro el sistema le de privilegios de admin + # Para un solo admin: + # - STAFF=hola@miemail.com + # Para varios admins: + # - STAFF=hola@miemail.com,usuario@otroemail.com,otrousuario@nuevoemail.com + - STAFF=hola@miemail.com + # Logos + - ORGANIZATION_EMAIL=miconsultapublica@midominio.com.ar + - ORGANIZATION_NAME="Mi Consulta Pública" + # Organizacion + - ORGANIZATION_EMAIL=miconsultapublica@midominio.com.ar + - ORGANIZATION_NAME="Mi Consulta Pública" + # Social media y email settings + - SOCIALSHARE_SITE_NAME="Mi Consulta Pública" + - SOCIALSHARE_SITE_DESCRIPTION="Plataforma de participación ciudadana" + - SOCIALSHARE_IMAGE=https://urlexterno.com/mi-imagen-externa.png #Cambiar + - SOCIALSHARE_DOMAIN=miconsultapublica.midominio.com #Cambiar + - SOCIALSHARE_TWITTER_USERNAME=@miConsultaPublica #Cambiar + - TWEET_TEXT="Estoy tratando de mejorar esta propuesta “{topic.mediaTitle}” ¡Participá vos también!" + # Configuracion del mailer + - NOTIFICATIONS_MAILER_EMAIL=miconsultapublica@midominio.com + - NOTIFICATIONS_MAILER_NAME="Mi consulta ṕública" + - NOTIFICATIONS_NODEMAILER={"host:"xxxxx.smtp.com","port":465,"secure":true,"auth":{"user":"xxxxxxxx","pass":"xxxxxxx"}} #Cambiar + # Requerido: Genere un token para JWT + - JWT_SECRET= #Cambiar + # Si desea activar Mi Argentina, descomente los siguientes puntos + # - CUSTOM_SIGNIN=true + # - OIDC_ISSUER= #Cambiar + # - OIDC_AUTH= #Cambiar + # - OIDC_TOKEN= #Cambiar + # - OIDC_USER= #Cambiar + # - OIDC_CLIENT_ID= #Cambiar + # - OIDC_CLIENT_SECRET= #Cambiar + # - OIDC_CALLBACK= #Cambiar + links: + - mongo + ports: + - 3000:3000 + volumes: + - ./ext/lib:/usr/src/ext/lib + - ./public:/usr/src/public + # Forced overrides of DemocracyOs + - ./dos-override/models/comment.js:/usr/src/lib/models/comment.js + - ./dos-override/api-v2/db-api/comments/index.js:/usr/src/lib/api-v2/db-api/comments/index.js + - ./dos-override/api-v2/db-api/comments/scopes.js:/usr/src/lib/api-v2/db-api/comments/scopes.js + tty: true + + mongo: + container_name: miconsultapublica-mongo + image: mongo:3.2 + ports: + - 27017:27017 + volumes: + - ./tmp/db:/data/db + +``` + +##### Notas +* Es muy importante que en `STAFF` agregues el email del admin o el de los administradores. +* Por defecto, tal com esta en el docker-compose, está en el puerto 3000. Puede cambiar el puerto el cual se expone la aplicación (Ej: `3000:9999`) +* Si se prefiere conectar a una base de dato local, fuera del entorno, vea el apartado [Conectar a una base de datos mongo local](#local-mongo) +* Podes configurar DemocracyOS con cualquiera de las variables de entorno listadas acá: http://docs.democracyos.org/configuration.html +* El puerto `27017` está expuesto para que puedas administrar la base de datos con algún cliente de MongoDB, por ejemplo con [Robomongo](https://robomongo.org/). +* Todas las vistas personalizadas para Consulta Pública se encuentran en [`/ext`](ext). Siguiendo el mismo patrón de carpetas que [DemocracyOS/democracyos](https://github.com/DemocracyOS/democracyos). + + +Luego de que todo este definido, podemos arrancar el servidor ejecutando: + +``` +docker-compose up --build +``` + +Puede tardar un rato largo en buildear. Cuando haya terminado y si todo sale bien, el servidor y container estarán correctamente levantados y listos para poder trabajar. + + +Para entrar a la aplicacion a [http://localhost:3000](http://localhost:3000) + + +## Comandos utiles + +Para abrir el server local + +``` +docker-compose up +``` + +Si cambia alguna dependencia del `/ext/package.json`, tiene que volver a buildear la imagen de Docker + +``` +docker-compose up --build +``` + +Para poder entrar al container de DemocracyOS: + +``` +docker exec -it miconsultapublica bash +``` + +Para poder entrar a la base de datos + +``` +docker exec -it miconsultapublica-mongo bash +``` +## Conectar a una base de dato Mongo local + +Si lo prefiere, puede conectar la aplicacion a su mongo local. En primer lugar aseguresé que sea **Mongo 3.2**, si no, procure utilizar el container que se construye en el build del docker-compose. + +Suponiendo que la base de datos esta en `localhost:27017` cambiar el valor de la variable `MONGO_URL` + +```yaml + app: + [...] + environment: + [...] + - MONGO_URL=mongodb://localhost:27017/mi-consultapublica +``` +Luego, debe comentar: + +```yaml + app: + [...] + # links: + # - mongo + [...] +``` +Y agregar: + +```yaml + app: + [...] + network_mode: "host" + [...] +``` + +Por ultimo debemos comentar el servicio de mongo, para que no se construya el container + +```yaml + # mongo: + # container_name: miconsultapublica-mongo + # image: mongo:3.2 + # ports: + # - 27017:27017 + # volumes: + # - ./tmp/db:/data/db +``` diff --git a/docs/manual-admin.md b/docs/manual-admin.md new file mode 100644 index 000000000..a7120cdef --- /dev/null +++ b/docs/manual-admin.md @@ -0,0 +1,178 @@ +# Manual instructivo para administradores + +* Introducción +* ¿Qué es [Consulta Pública](https://consultapublica.argentina.gob.ar/)? +* ¿Qué es [Consulta Pública](https://consultapublica.argentina.gob.ar) distribuible? +* ¿Cómo hago para implementar una versión de Consulta Pública? +* ¿Cómo está estructurada la plataforma? +* ¿Para qué sirven los ejes? +* ¿Cómo administrar mi plataforma y sus consultas? +* ¿Cómo crear una nueva consulta? +* ¿Cómo administrar una Consulta? +* Buenas prácticas + + +## Introducción +Este **manual instructivo** está destinado a los administradores de organismos subnacionales que implementen una versión de la plataforma Consulta Pública, suministrada por la Secretaria de Modernización. +## ¿Qué es [Consulta Pública](https://consultapublica.argentina.gob.ar/)? +Consulta Pública es un canal de diálogo online y debate que permite la interacción entre el gobierno y la consulta, promueve la participación ciudadana y ayuda a fortalecer la democracia. + +En Consulta Pública se abren temas e iniciativas a la ciudadanía, para posibilitar la participación a través de comentarios y opiniones con el objetivo de mejorar las políticas implementadas. A esta instancia de participación la llamamos **“Consulta Pública”.** + +En la plataforma se pueden crear múltiples consultas digitales por un plazo determinado sobre las temáticas que se quieran abrir a consideración. +## ¿Qué es [Consulta Pública](https://consultapublica.argentina.gob.ar) distribuible? +Es una versión de la plataforma Consulta Pública que tiene por objetivo ser distribuida a distintos organismos subnacionales. Su implementación en estas áreas permite la creación de canal digital de diálogo entre gobierno y ciudadanía. + +Esta Plataforma tiene un desarrollo basado en la adaptación del código abierto **DemocracyOS,** diseñada para informar, debatir y votar propuestas públicas hacia la construcción de una democracia adaptada al siglo XXI. Este código fue suministrado por Democracia en Red y se encuentra a disposición de la Secretaría de Modernización. +## ¿Cómo hago para implementar una versión de Consulta Pública? +Para tener una versión de Consulta Pública, por favor escribinos a gobiernoabierto@modernizacion.gob.ar + +-------------------------------------------------------------------- +Ya sos **organizador** de tu plataforma de Consulta Pública. Esto quiere decir que sos el responsable de crear las consultas. También podés asignar a otras áreas la posibilidad de gestionar consultas dentro de la plataforma. + +Esta guía te ayudará a: + + - conocer los elementos de la Plataforma e identificar dónde se encuentran. + +- administrar la plataforma. + +- crear y editar las consultas. + +- asignar diferentes roles o **privilegios** a los usuarios. + +Continuá leyendo para saber cómo utilizar la Plataforma Consulta Pública. + +## ¿Cómo está estructurada la plataforma? + +En la pantalla de inicio, las consultas se muestran por los filtros de **“nuevas”, “relevantes” y “finalizadas”.** Cada consulta está dividida en **ejes**, que comprenden distintos aspectos del tema abierto a debate. + +![Imagen](/docs/admin-manual/image1.png?raw=true) + +Al ingresar en la consulta, se visualiza un espacio con un **información general** que contiene una explicación de dónde surge, qué se espera con la consulta, a qué público está destinada y donde se compartirá toda la información vinculada a la misma necesaria para que las personas que quieren participar cuenten con recursos para hacerlo. + +![Imagen](/docs/admin-manual/image2.png?raw=true) + +## ¿Para qué sirven los ejes? +La consulta debe estar organizada en **“ejes”.** El eje es el espacio donde se realiza la participación y la moderación de los comentarios de la ciudadanía. La elección de la cantidad de ejes está determinada por el objetivo de la consulta y naturaleza del objeto a consultar. Puede dividirse en componentes temáticos, segmentar por el público involucrado o armar ejes sobre aspectos transversales. + +![Imagen](/docs/admin-manual/image3.png?raw=true) + +## ¿Cómo administrar mi plataforma y sus consultas? +Para ingresar al **panel de administración**, hacé click en “MIS CONSULTAS”. + +![Imagen](/docs/admin-manual/image4.png?raw=true) + +En **ajustes** podés configurar tu perfil, contraseña, notificaciones y administrar las consultas. + +![Imagen](/docs/admin-manual/image5.png?raw=true) + +## ¿Cómo crear una nueva consulta? +Las **consultas** son creadas únicamente por el organizador de la Plataforma. En la opción **“administración”** dentro del panel se visualizan, crean y editan las consultas creadas. + +Al crear una consulta, debés completar todos los campos que figuran a continuación: + + - URL de la consulta: Este campo habilita un cambio en la URL de acceso directo a la consulta. + - Título: Editar el título de la consulta + - Resumen: Editar el texto qué se muestra en la página principal, debajo del título de la consulta. + - Resumen largo: Editar el contenido visible en la vista de la consulta. + - Imagen de la portada: Campo a completar con la URL de una imagen qué servirá como portada de la consulta. + - Fecha de cierre: Para determinar la fecha de cierre de la consulta. Una vez “finalizada” se verá en la página de inicio al aplica el filtro “finalizadas”. + - Ocultar: La consulta tiene la opción de ocultar. Al marcar este casillero, la consulta no será accesible a través de la página de inicio. Únicamente se podrá acceder a través de la URL de la consulta. Esta opción nos permite que participen usuarios específicos a los qué les pasemos la URL, manteniendo el control de los usuarios qué queremos qué participen. + - Autor: Este campo permite cambiar el autor visible en la consulta. + - Autor URL: Sitio web, enlace al autor u organismo responsable que realiza la consulta. En la vista al hacer click en autor te derivará a ese enlace. + +![Imagen](/docs/admin-manual/image6.png?raw=true) + +Una vez creada las consultas, las podés encontrar en **“MIS CONSULTAS”** y editar desde el ícono del *engranaje*: + +![Imagen](/docs/admin-manual/image7.png?raw=true) + +## ¿Cómo administrar una Consulta? +Cada consulta tiene su propia configuración de temas (ejes), privilegios, contenido (editar consulta), comentarios y estadísticas y moderación de etiquetas. + +![Imagen](/docs/admin-manual/image8.png?raw=true) + + 1) **Temas**: permite editar y agregar **ejes** a la consulta. + a) para **crear** un eje, hacé click en “nuevo”. + b) En esta misma pantalla se pueden ver los ejes creados. Para **editarlos**, se debe hacer click sobre el mismo. + c) Para **eliminar** un eje creado se debe hacer click en el icono del cesto de basura. + +![Imagen](/docs/admin-manual/image9.png?raw=true) + +En el formulario **“edición de tema”** para la creación de ejes, los campos a completar son: + +Título: escribir el título del eje. Se recomienda asignar un número asociado al orden. + +Categoría: + + Atributos: escribir la pregunta que será definida en el eje de la consulta. Este contenido depende de la acción elegida. + +Cover URL: Para colocar una imágen de fondo para el eje, la misma debe estar en un repositorio y copiar el vínculo. + + Autor: Nombre del autor u organismo responsable que realiza la consulta. + +Autor URL: Sitio web, enlace al autor u organismo responsable que realiza la consulta. En la vista del eje al hacer click en “autor” te derivará a ese enlace. + +Acción: En todos los ejes está disponible el **foro debate**, que permite a los usuarios realizar comentarios, responder comentarios de otros y valorar positiva o negativamente esos comentarios. Además, se puede **complementar** con **una sola** de estas acciones: voto, encuesta, causa, rango y jerarquía. La acción se elige en el **“formulario de edición de eje”.** + +![Imagen](/docs/admin-manual/image10.png?raw=true) + +Cada una de las acciones tiene su *formato predeterminado*. Lo que aparece en el eje cambia según la acción seleccionada. +- **Voto:** se muestra un panel con la pregunta definida en el campo de “Atributos” con las opciones de votar “afirmativo”, “negativo” o “abstención”. +- **Encuesta:** aparece la caja con las opciones para la encuesta. Escribir una palabra o frase que se insertará como una opción cuando se presione el botón “enter”. Esto hará que en el eje se muestre un panel con la pregunta definida en el campo de “Atributos” seguido de las opciones entre las cuales el usuario podrá seleccionar. +- **Causa:** se muestra un panel con la pregunta/frase definida en el campo de “Atributos” con la opción de demostrar apoyo ante ese postulado. +- **Rango:** en el eje se muestra un panel con la pregunta definida en el campo de “Atributos”, y con una barra deslizante (*slider*) que permitirá al usuario indicar en qué nivel se encuentra a favor o en en contra con la consulta/ pregunta realizada. +- **Jerarquía:** se muestra un panel con la pregunta definida en el campo de “Atributos”, con las opciones que deberán ser arrastradas haciendo click y asignada en un nuevo orden, que dé cuenta de la jerarquía. + +Fuente: link de donde se obtuvo la información del contenido. + +Contenido: Este campo es para desarrollar el texto que ayudará a los usuarios interiorizarse sobre el tema que se busca debatir. +Dentro de la opción contenido se pueden agregar **“atajos de navegación”** qué facilitan la navegabilidad a través del contenido de eje en la versión web de la plataforma. + +![Imagen](/docs/admin-manual/image11.png?raw=true) + +Fecha de cierre: define el límite de hasta cuando se puede interactuar con eje. + +Referencias: espacio para un link adicional con referencias para sumar al contenido. + +La consulta, una vez cargada, debe ser **guardada**. Existe la opción de “publicar” en el momento de iniciar la consulta. + +![Imagen](/docs/admin-manual/image12.png?raw=true) + +2. **Privilegios:** esta opción permite configurar la visibilidad y los permisos de la Consulta. +*Visibilidad*: se puede elegir la visibilidad de la consulta entre las opciones “secreta”, “restringida”, “pública” y “colaborativa”. + +![Imagen](/docs/admin-manual/image13.png?raw=true) + +*Permisos*: existen distintos tipos de permisos en Consulta Pública, según los **privilegios** que posea. En esta opción, el *admin* agregar o modificar organizadores y administradores. + + - **organizador:** es único (superadmin) y nadie puede quitarle permisos. Posee permisos para realizar todas las acciones posibles. Es el encargado de crear "las consultas". +- **administradores:** pueden otorgar permisos a otros usuarios, editar opciones y editar contenido (crear y editar ejes, etc). +- **colaboradores:** pueden crear, editar, borrar y publicar ejes. +- **autores:** pueden crear y editar ejes, pero no publicarlos o editarlos. + +![Imagen](/docs/admin-manual/image14.png?raw=true) + +3. **Editar consulta:** en caso de querer modificar la información general sobre la consulta y controlar algunas opciones, se pueden realizar **cambios** en los mismos campos completados cuando se creó la consulta. + +![Imagen](/docs/admin-manual/image15.png?raw=true) + +4. **Comentarios y estadísticas:** Desde este panel se pueden observar las estadísticas por consultas y visualizar los comentarios, identificar los que están respondidos, responder y realizar una clasificación de los mismos. +(falta foto de este panel) + +5. **Moderación de etiquetas:** Creación y edición de etiquetas. + +## Buenas prácticas + + - Se recomienda consultar sobre una acción o política que tenga margen para nutrirse del debate y aportes de la ciudadanía y que derive en una medida tangible. + +- La estructura de la Consulta debe ser de fácil acceso y evitar el lenguaje técnico. Debe contener: un resumen del **tema**, el **objetivo** que se quiere alcanzar, a qué **público** está dirigida, los **plazos** de la consulta, una descripción de la **promesa** y los **términos y condiciones**, un **contacto** y una explicación sobre qué se hará con los **resultados** de la Consulta. + +- Para motivar la participación, se recomienda estar centrado en las **necesidades y requerimientos** de los/las ciudadanas, usar un **tono cercano** que genere empatía, **lenguaje universal**, **flexibilidad** al explicar contenido técnico y el uso de **“vos o usted”**, evitando el uso de tercera persona. + + +- Es importante desarrollar una **estrategia de comunicación** para que la participación ciudadana sea efectiva. + +- Se recomienda responder a los **comentarios** en plazos cortos y evitar respuestas reiteradas. El **monitoreo** de comentarios es útil para hacer un seguimiento de las interacciones en la Consulta. + +- Hacer un **cierre** al final de la Consulta, informando, agradeciendo y compartiendo información del impacto efectivo. \ No newline at end of file diff --git a/docs/manual-usuarios.md b/docs/manual-usuarios.md new file mode 100644 index 000000000..1a805966e --- /dev/null +++ b/docs/manual-usuarios.md @@ -0,0 +1,52 @@ +# Manual instructivo para usuarios + +* ¿Cómo crear un usuario? +* ¿Cómo configurar tu perfil? +* ¿Cómo está estructurado el sitio? +* ¿Cómo participo? +* ¿Cómo puedo compartir una consulta? + + +## ¿Cómo crear un usuario? +Hacer click en la opción “Ingresar”, ubicada en la parte superior derecha de la pantalla. Para registrarte, debes elegir la opción “registrarse”, completar tus datos y enviar el formulario. Luego, recibirás un mail en tu correo electrónico con un link para validar tu usuario. Una vez validado, podés participar. + +![Imagen](/docs/usuario-manual/image1.png?raw=true) + +## ¿Cómo configurar tu perfil? +Una vez registrada/o, en la parte superior derecha de la pantalla aparecerá tu nombre de usuario. Con un click sobre tu nombre, aparecerá un menú desplegable. + +![Imagen](/docs/usuario-manual/image2.png?raw=true) + +En **"Configuración",** en caso que quieras, podés definir tus datos personales (nombre, apellido) y cambiar la foto de perfil pegando la URL de una imagen tuya. La forma más sencilla de hacerlo es ir a cualquier de tus redes sociales y en la foto que quieras, hacer click derecho y seleccionar “copiar URL de la imagen”. Otra forma de hacerlo es subir alguna imagen a un servidor (como por ejemplo [https://es.imgbb.com/](https://es.imgbb.com/) ) y hacer el mismo proceso. Además, se puede cambiar la contraseña y determinar las preferencias de las notificaciones (recibirás un mail cuando responden a un comentario o cuando se creen nuevas consultas). + +## ¿Cómo está estructurado el sitio? +En Consulta Pública se tratan temas e iniciativas, abiertas a las opiniones de la ciudadanía, con el objetivo de aumentar la participación y fortalecer la democracia. A estos temas e iniciativas las llamamos de “Consulta”. +Las consultas se muestran por los filtros de “nuevas”, “relevantes” y “finalizadas”. La consulta está dividida en **ejes**, que comprenden distintos aspectos del tema abierto a debate. Se puede ingresar a los ejes directamente desde la pantalla principal. + +![Imagen](/docs/usuario-manual/image3.png?raw=true) + +O elegir **“ver más información”**, donde hay una descripción y explicación de la consulta. Debajo, también figuran los ejes disponibles. +Cada **eje** está abierto para participar durante un periodo de tiempo desde la fecha de publicación. + +![Imagen](/docs/usuario-manual/image4.png?raw=true) + +## ¿Cómo participo? + +Se puede participando realizando comentarios y aportes en el **foro de debate.** Dentro de cada eje publicado, existe un espacio para hacer comentarios sobre las temáticas y expresarse sobre la discusión abierta. Una vez finalizado el tiempo estipulado de la consulta, no se puede seguir participando. + +![Imagen](/docs/usuario-manual/image5.png?raw=true) + +También, se pueden **interactuar** con los comentarios de otras personas para debatir puntos de vista o aportar más información, contestando o valorando positiva o negativamente sus argumentos (con el “pulgar arriba” o “pulgar abajo”). Los comentarios se pueden ordenar de acuerdo a su “relevancia” o cronológicamente (“más recientes” o “más antiguos”). +Además se puede participar votando. Cada eje tiene como posibilidad agregar una **acción específica.** Hay cinco tipos de acciones adicionales: + + - **Voto**: contiene las opciones “a favor”, “abstención” y “en contra”. + - **Encuesta**: se presenta un listado de opciones para elegir una de ellas. + - **Causa**: contiene una opción que permite apoyar la causa presentada en el eje. + - **Rango**: en base a una pregunta, se posiciona la respuesta en un rango que escala desde “no estoy de acuerdo” hasta “estoy a favor”. + - **Jerarquía**: de una lista de opciones, esta opción permite ordenarlas de mayor a menor en base a un criterio. + +![Imagen](/docs/usuario-manual/image6.png?raw=true) +*Ejemplo de una de las acciones* + +## ¿Cómo puedo compartir una consulta? +Bajo el texto de cada propuesta, vas a encontrar las opciones de compartir por Facebook o por Twitter. \ No newline at end of file diff --git a/docs/personalizacion.md b/docs/personalizacion.md new file mode 100644 index 000000000..093c507e5 --- /dev/null +++ b/docs/personalizacion.md @@ -0,0 +1,82 @@ +# Personalización + +Todo lo que necesita para adaptar su plataforma es modificar textos o imagenes bajo la carpeta `/ext/site`. + +A continuacion damos un listado importantes + +## Imagenes para cambiar + +- Icono del navbar y footer: `/ext/lib/site/home-multiforum/assets/logo_consulta-publica.svg` +- Background del header del home: `/ext/lib/site/home-multiforum/assets/header_consulta-publica.png` +- Iconos del listado del home: + - `/ext/lib/site/home-multiforum/assets/icono_consulta-publica-1.svg` + - `/ext/lib/site/home-multiforum/assets/icono_consulta-publica-2.svg` + - `/ext/lib/site/home-multiforum/assets/icono_consulta-publica-3.svg` +- Iconos del header: `/ext/lib/site/home-multiforum/assets/logo-header.svg` +- Iconos del footer: `/ext/lib/site/footer/assets/logo-footer.svg` + +## Textos para cambiar + +Se pueden cambiar cualquier texto dentro de `/ext/lib/site`. Algunas vistas pueden llegar a utilizar algun componente de **i18n** donde la funcion `t("mi.label")` toma del archivo de traduccion `es.json` ubicado en `/ext/translations/lib/es.json` + +Si hay etiquetas de i18n que no se encuentran en el `es.json` lo mas probable es que vengan del core de DemocracyOS. + +Si se quiere ver esos archivos, lo mas conveniente es entrar al bash del container o ver el repositorio de DemocracyOS en GitHub. + +Para entrar al bash del container, ejecutar: + +``` +docker exec -it bash +``` + +El bash abre en `/usr/src` y ahi se encontraria el codigo de todo DemocracyOS y la carpeta ext que es de este repositorio. + +## Vistas + +Para overraidear vistas lo mejor es partir de la implementacion de DemocracyOS (entrando a su bash) y hacer su copia en la carpeta `/ext/lib` + +Se hacen las modificaciones y se tiene que declarar su override en el archivo `/ext/lib/site/boot/overrides.js` + +Este seria un ejemplo de como overraidear una vista de DemocracyOS por una personalizada. + +```js +import 'ext/lib/boot/overrides' + +import * as HomeForum from 'lib/site/home-forum/component' +import HomeForumExt from 'ext/lib/site/home-forum/component' + +import * as HomeMultiForum from 'lib/site/home-multiforum/component' +import HomeMultiForumExt from 'ext/lib/site/home-multiforum/component' + +import * as TopicLayout from 'lib/site/topic-layout/component' +import TopicLayoutExt from 'ext/lib/site/topic-layout/component' + +import * as Help from 'lib/site/help/component' +import HelpExt from 'ext/lib/site/help/component' + +import * as SignIn from 'lib/site/sign-in/component' +import SignInExt from 'ext/lib/site/sign-in/component' + + +HomeForum.default = HomeForumExt +HomeMultiForum.default = HomeMultiForumExt +TopicLayout.default = TopicLayoutExt +Help.default = HelpExt +SignIn.default = SignInExt +``` + +## Assets + +Cada carpeta dentro de `/ext/lib/site` cuenta con las vistas y cada una de ellas puede contar con una carpeta `assets` del cual el componente puede referenciar a esta carpeta. + +Cuando se realiza el build, la estructura de carpetas se mantiene. O sea, si tengo un asset en `/ext/lib/site/home-multiforum/assets/logo_consulta-publica.svg` entonces en la URL lo tendre en `http://localhost:3000/ext/lib/site/home-multiforum/logo_consulta-publica.svg` + +Note que en el codigo del componente de home-multiforum se referencia usando `url()` o si es un tag `` con `src=` se hace asi: + +``` +Logo +``` \ No newline at end of file diff --git a/docs/usuario-manual/image1.png b/docs/usuario-manual/image1.png new file mode 100644 index 000000000..547e6ec46 Binary files /dev/null and b/docs/usuario-manual/image1.png differ diff --git a/docs/usuario-manual/image2.png b/docs/usuario-manual/image2.png new file mode 100644 index 000000000..5fe13591f Binary files /dev/null and b/docs/usuario-manual/image2.png differ diff --git a/docs/usuario-manual/image3.png b/docs/usuario-manual/image3.png new file mode 100644 index 000000000..928256928 Binary files /dev/null and b/docs/usuario-manual/image3.png differ diff --git a/docs/usuario-manual/image4.png b/docs/usuario-manual/image4.png new file mode 100644 index 000000000..bc2081001 Binary files /dev/null and b/docs/usuario-manual/image4.png differ diff --git a/docs/usuario-manual/image5.png b/docs/usuario-manual/image5.png new file mode 100644 index 000000000..86e013665 Binary files /dev/null and b/docs/usuario-manual/image5.png differ diff --git a/docs/usuario-manual/image6.png b/docs/usuario-manual/image6.png new file mode 100644 index 000000000..c3fe5e808 Binary files /dev/null and b/docs/usuario-manual/image6.png differ diff --git a/dos-override/api-v2/db-api/comments/index.js b/dos-override/api-v2/db-api/comments/index.js new file mode 100644 index 000000000..acd503c31 --- /dev/null +++ b/dos-override/api-v2/db-api/comments/index.js @@ -0,0 +1,650 @@ +const Comment = require('lib/models').Comment +const privileges = require('lib/privileges/forum') +const scopes = require('./scopes') + +/** + * Default find Method, to be used in favor of Model.find() + * @method find + * @param {object} query - Mongoose query options + * @return {Mongoose Query} + */ +function find (query) { + return Comment.find(Object.assign({ + context: 'topic' + }, query)) +} + +exports.find = find + +/** + * Get the public listing of comments from a topic + * @method list + * @param {object} opts + * @param {objectId} opts.topicId + * @param {number} opts.limit - Amount of results per page + * @param {number} opts.page - Page number + * @param {document} opts.user - User data is beign fetched for + * @param {('score'|'-score'|'createdAt'|'-createdAt')} opts.sort + * @return {promise} + */ +exports.list = function list (opts) { + opts = opts || {} + + return find() + .where({ reference: opts.topicId }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .limit(opts.limit) + .skip((opts.page - 1) * opts.limit) + .sort(opts.sort) + .exec() + .then((comments) => comments.map((comment) => { + return scopes.ordinary.expose(comment, opts.user) + })) +} +/** + * Get the public listing of comments from a topic + * @method adminlist + * @param {object} opts + * @param {objectId} opts.topicId + * @param {number} opts.limit - Amount of results per page + * @param {number} opts.page - Page number + * @param {document} opts.user - User data is beign fetched for + * @param {('score'|'-score'|'createdAt'|'-createdAt')} opts.sort + * @return {promise} + */ +exports.adminlist = function adminlist (opts) { + opts = opts || {} + + return find() + .where({ reference: opts.topicId }) + .populate(scopes.ordinary.populate) + .select('id text createdAt editedAt reference flags score repliesCount replies author votes context adminMarks') + .limit(opts.limit) + .skip((opts.page - 1) * opts.limit) + .sort(opts.sort) + .exec() + .then((comments) => comments.map((comment) => { + return scopes.ordinary.exposeToAdmin(comment, opts.user) + })) +} + +/** + * Get the count of total commenters + * @method listCount + * @param {object} opts + * @param {objectId} opts.topicId + * @return {promise} + */ +exports.commentersCount = function commentersCount (opts) { + opts = opts || {} + + return find() + .where({ reference: opts.topicId }) + .exec() + .then(comments => { + const replies = comments + .reduce((acc, commentReplies) => acc.concat(commentReplies), []) + + const count = comments.concat(replies) + .map(comment => comment.author.toString()) + .filter((comment, index, commentsArr) => commentsArr.indexOf(comment) === index) + .length + + return count + }) +} + +/** + * Get the count of total comments of the public listing + * @method listCount + * @param {object} opts + * @param {objectId} opts.topicId + * @return {promise} + */ +exports.listCount = function listCount (opts) { + opts = opts || {} + + return find() + .where({ reference: opts.topicId }) + .count() + .exec() +} + +/** + * Create or Update a vote on a comment + * @method vote + * @param {object} opts + * @param {string} opts.id - Comment Id + * @param {document} opts.user - Author of the vote + * @param {('positive'|'negative')} opts.value - Vote value + * @return {promise} + */ +exports.vote = function vote (opts) { + const id = opts.id + const user = opts.user + const value = opts.value + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(verifyAutovote.bind(null, user)) + .then(doVote.bind(null, user, value)) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + +function verifyAutovote (user, comment) { + if (comment.author.equals(user._id)) { + const err = new Error('A user can\'t vote his own comment.') + err.code = 'NO_AUTOVOTE' + err.status = 400 + throw err + } + return comment +} + +function doVote (user, value, comment) { + return new Promise((resolve, reject) => { + comment.vote(user, value, function (err) { + if (err) return reject(err) + resolve(comment) + }) + }) +} + +/** + * Create or Update a vote on a comment + * @method vote + * @param {object} opts + * @param {string} opts.id - Comment Id + * @param {document} opts.user - Author of the vote + * @param {('positive'|'negative')} opts.value - Vote value + * @return {promise} + */ +exports.unvote = function unvote (opts) { + const id = opts.id + const user = opts.user + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(doUnvote.bind(null, user)) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + +function doUnvote (user, comment) { + return new Promise((resolve, reject) => { + comment.unvote(user, function (err) { + if (err) return reject(err) + resolve(comment) + }) + }) +} + +/** + * Create a comment + * @method vote + * @param {object} opts + * @param {string} opts.text - Comment text + * @param {document} opts.user - Author of the comment + * @param {document} opts.topicId - Topic where the comment is beign created + * @return {promise} + */ +exports.create = function create (opts) { + const text = opts.text + const user = opts.user + const topicId = opts.topicId + + return Comment + .create({ + text: text, + context: 'topic', + reference: topicId, + author: user + }) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + +/** + * Create a reply + * @method reply + * @param {object} opts + * @param {string} opts.text - Reply text + * @param {document} opts.user - Author of the comment + * @param {document} opts.id - Comment id + * @return {promise} + */ +exports.reply = function reply (opts) { + const text = opts.text + const user = opts.user + const id = opts.id + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(doReply.bind(null, user, text)) + .then((results) => ({ + comment: scopes.ordinary.expose(results.comment, user), + reply: results.reply + })) +} + +function doReply (user, text, comment) { + return new Promise((resolve, reject) => { + const reply = comment.replies.create({ + text: text, + author: user + }) + + comment.replies.push(reply) + + comment.save((err, commentSaved) => { + if (err) reject(err) + + resolve({ + comment: commentSaved, + reply: reply + }) + }) + }) +} + +/** + * Delete comment + * @method delete + * @param {object} opts + * @param {document} opts.user - Author of the comment + * @param {document} opts.id - Comment id + * @return {promise} + */ +exports.removeComment = (function () { + function verifyPrivileges (forum, user, comment) { + if (privileges.canDeleteComments(forum, user)) return comment + + if (!comment.author.equals(user._id)) { + const err = new Error('Can\'t delete comments from other users') + err.code = 'NOT_YOURS' + err.status = 400 + throw err + } + + return comment + } + + function verifyNoReplies (forum, user, comment) { + if (privileges.canDeleteComments(forum, user)) return comment + if (comment.replies.length > 0 && !user.staff) { + const err = new Error('Can\'t delete comments with replies') + err.code = 'HAS_REPLIES' + err.status = 400 + throw err + } + return comment + } + + function doRemoveComment (comment) { + return new Promise((resolve, reject) => { + comment.remove((err) => { + if (err) reject(err) + resolve() + }) + }) + } + + return function removeComment (opts) { + const id = opts.id + const forum = opts.forum + const user = opts.user + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(verifyPrivileges.bind(null, forum, user)) + .then(verifyNoReplies.bind(null, forum, user)) + .then(doRemoveComment) + } +})() + +/** + * Delete comment reply + * @method delete + * @param {object} opts + * @param {document} opts.user - Author of the comment + * @param {document} opts.id - Comment id + * @param {document} opts.replyId - Reply id + * @return {promise} + */ +exports.removeReply = (function () { + function verifyPrivileges (forum, user, replyId, comment) { + if (privileges.canDeleteComments(forum, user)) return comment + + const reply = comment.replies.id(replyId) + + if (!reply.author.equals(user.id)) { + const err = new Error('Can\'t delete replies from other users') + err.code = 'NOT_YOURS' + err.status = 400 + throw err + } + + return comment + } + + function doRemoveReply (user, replyId, comment) { + const reply = comment.replies.id(replyId) + + return new Promise((resolve, reject) => { + reply.remove() + comment.save(function (err, _comment) { + if (err) reject(err) + resolve(_comment) + }) + }) + } + + return function removeReply (opts) { + const id = opts.id + const user = opts.user + const forum = opts.forum + const replyId = opts.replyId + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(verifyPrivileges.bind(null, forum, user, replyId)) + .then(doRemoveReply.bind(null, user, replyId)) + .then((comment) => scopes.ordinary.expose(comment, user)) + } +})() + +/** + * Flag comment + * @method vote + * @param {object} opts + * @param {string} opts.id - Comment Id + * @param {document} opts.user - Author of the vote + * @return {promise} + */ +exports.flag = function flag (opts) { + const id = opts.id + const user = opts.user + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(verifyAutoFlag.bind(null, user)) + .then(doFlag.bind(null, user)) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + +function verifyAutoFlag (user, comment) { + if (comment.author.equals(user._id)) { + const err = new Error('A user can\'t flag his own comment.') + err.code = 'NO_AUTO_FLAG' + throw err + } + return comment +} + +function doFlag (user, comment) { + return new Promise((resolve, reject) => { + comment.flag(user, 'spam', function (err) { + if (err) return reject(err) + resolve(comment) + }) + }) +} + +/** + * Unflag comment + * @method vote + * @param {object} opts + * @param {string} opts.id - Comment Id + * @param {document} opts.user - Author of the vote + * @return {promise} + */ +exports.unflag = function flag (opts) { + const id = opts.id + const user = opts.user + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(verifyAutoUnflag.bind(null, user)) + .then(doUnflag.bind(null, user)) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + +function verifyAutoUnflag (user, comment) { + if (comment.author.equals(user._id)) { + const err = new Error('A user can\'t flag his own comment.') + err.code = 'NO_AUTO_FLAG' + throw err + } + return comment +} + +function doUnflag (user, comment) { + return new Promise((resolve, reject) => { + comment.unflag(user, function (err) { + if (err) return reject(err) + resolve(comment) + }) + }) +} + +/** + * Mark comment + * @method vote + * @param {object} opts + * @param {string} opts.id - Comment Id + * @param {document} opts.user - Author of the vote + * @return {promise} + */ +exports.mark = function mark (opts) { + const id = opts.id + const mark = opts.mark + const user = opts.user + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.selectToAdmin) + .exec() + .then(doMark.bind(null, mark)) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + + +function doMark (value, comment) { + return new Promise((resolve, reject) => { + comment.mark(value, function (err) { + if (err) return reject(err) + resolve(comment) + }) + }) +} + +/** + * Unflag comment + * @method vote + * @param {object} opts + * @param {string} opts.id - Comment Id + * @param {document} opts.user - Author of the vote + * @return {promise} + */ +exports.unmark = function unmark (opts) { + const id = opts.id + const mark = opts.mark + const user = opts.user + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.selectToAdmin) + .exec() + .then(doUnmark.bind(null, mark)) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + + +function doUnmark (value, comment) { + return new Promise((resolve, reject) => { + comment.unmark(value, function (err) { + if (err) return reject(err) + resolve(comment) + }) + }) +} + +/** + * Edit comment + * @method vote + * @param {object} opts + * @param {string} opts.id - Comment Id + * @param {string} opts.text - Comment body + * @param {document} opts.user - Author of the vote + * @return {promise} + */ +exports.edit = function edit (opts) { + const id = opts.id + const user = opts.user + const text = opts.text + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(verifyAuthorEdit.bind(null, user)) + .then(doEdit.bind(null, text)) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + +function verifyAuthorEdit (user, comment) { + if (!comment.author.equals(user._id)) { + const err = new Error('A user can\'t edit other users comments.') + err.code = 'NOT_YOURS' + throw err + } + return comment +} + +function doEdit (text, comment) { + return new Promise((resolve, reject) => { + comment.text = text + comment.editedAt = Date.now() + comment.save(function (err, comment) { + if (err) return reject(err) + resolve(comment) + }) + }) +} + +/** + * Edit reply + * @method vote + * @param {object} opts + * @param {string} opts.id - Comment Id + * @param {string} opts.text - Comment body + * @param {document} opts.user - Author of the vote + * @return {promise} + */ +exports.editReply = function editReply (opts) { + const id = opts.id + const replyId = opts.replyId + const user = opts.user + const text = opts.text + + return find() + .findOne() + .where({ _id: id }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(verifyAuthorEditReply.bind(null, user, replyId)) + .then(doEditReply.bind(null, text, replyId)) + .then((comment) => scopes.ordinary.expose(comment, user)) +} + +function verifyAuthorEditReply (user, replyId, comment) { + const reply = comment.replies.id(replyId) + if (!reply.author.equals(user._id)) { + const err = new Error('A user can\'t edit other users replies.') + err.code = 'NOT_YOURS' + throw err + } + return comment +} + +function doEditReply (text, replyId, comment) { + return new Promise((resolve, reject) => { + const reply = comment.replies.id(replyId) + reply.text = text + reply.editedAt = Date.now() + comment.save(function (err, comment) { + if (err) return reject(err) + resolve(comment) + }) + }) +} + +/** + * Populate topics with their comments + * @method vote + * @param {object} opts + * @param {Array} topics - List of topics + * @return {promise} + */ +exports.populateTopics = function populateTopics (topics) { + let topicIds = topics.map((topic) => topic.id) + + topics = topics.map((topic) => { + topic.comments = [] + return topic + }) + + return find({ reference: { $in: topicIds } }) + .populate(scopes.ordinary.populate) + .select(scopes.ordinary.select) + .exec() + .then(function (comments) { + if (!comments) { + const err = new Error(`All comments csv not found.`) + err.status = 404 + err.code = 'ALL_COMMENTS_CSV_NOT_FOUND' + return err + } + + comments.forEach((comment) => { + const topicIn = topicIds.indexOf(comment.reference) + topics[topicIn].comments.push(scopes.ordinary.expose(comment)) + }) + + return topics + }) +} diff --git a/dos-override/api-v2/db-api/comments/scopes.js b/dos-override/api-v2/db-api/comments/scopes.js new file mode 100644 index 000000000..4243d4c8a --- /dev/null +++ b/dos-override/api-v2/db-api/comments/scopes.js @@ -0,0 +1,112 @@ +const expose = require('lib/utils').expose +const userScopes = require('../users/scopes') + +exports.ordinary = {} + +exports.ordinary.keys = { + expose: [ + 'id', + 'text', + 'createdAt', + 'editedAt', + 'reference', + 'flags', + 'score', + 'repliesCount', + 'replies' + ], + + select: [ + 'replies', + 'author', + 'votes', + 'context' + ] +} +exports.ordinary.keysForAdmin = { + expose: [ + 'id', + 'text', + 'createdAt', + 'editedAt', + 'reference', + 'flags', + 'score', + 'repliesCount', + 'replies', + 'adminMarks' + ], + + select: [ + 'replies', + 'author', + 'votes', + 'context' + ] +} + +exports.ordinary.populate = { + path: 'author replies.author', + select: userScopes.ordinary.select +} + +exports.ordinary.select = exports.ordinary.keys.expose.concat( + exports.ordinary.keys.select +).join(' ') + +exports.ordinary.selectToAdmin = exports.ordinary.keysForAdmin.expose.concat( + exports.ordinary.keysForAdmin.select +).join(' ') + +exports.ordinary.expose = (function () { + const exposeFields = expose(exports.ordinary.keys.expose.concat( + userScopes.ordinary.keys.expose.map((v) => `author.${v}`) + )) + + function exposeComment (comment) { + const json = exposeFields(comment) + + json.replies.forEach((reply) => { + reply.author = userScopes.ordinary.expose(reply.author) + }) + + return json + } + + return function ordinaryExpose (comment, user) { + const json = exposeComment(comment.toJSON()) + if (user) json.currentUser = currentUserFields(comment, user) + return json + } +})() + +exports.ordinary.exposeToAdmin = (function () { + const exposeFields = expose(exports.ordinary.keysForAdmin.expose.concat( + userScopes.ordinary.keys.expose.map((v) => `author.${v}`) + )) + + function exposeComment (comment) { + const json = exposeFields(comment) + + json.replies.forEach((reply) => { + reply.author = userScopes.ordinary.expose(reply.author) + }) + + return json + } + + return function ordinaryExpose (comment, user) { + const json = exposeComment(comment.toJSON()) + if (user) json.currentUser = currentUserFields(comment, user) + return json + } +})() + +function currentUserFields (comment, user) { + const vote = comment && comment.voteOf(user) + return { + voted: !!vote, + upvoted: !!vote && vote.value === 'positive', + downvoted: !!vote && vote.value === 'negative' + } +} diff --git a/dos-override/models/comment.js b/dos-override/models/comment.js new file mode 100644 index 000000000..b530eac54 --- /dev/null +++ b/dos-override/models/comment.js @@ -0,0 +1,259 @@ +var mongoose = require('mongoose') +var Schema = mongoose.Schema +var ObjectId = Schema.ObjectId +var log = require('debug')('democracyos:comment-model') + +/** + * Comment Vote Schema + */ + +var Vote = new Schema({ + author: { type: ObjectId, ref: 'User', required: true }, + value: { type: String, enum: [ 'positive', 'negative' ], required: true }, + createdAt: { type: Date, default: Date.now } +}) + +/** + * Comment Flag Schema + */ + +var Flag = new Schema({ + author: { type: ObjectId, ref: 'User', required: true }, + value: { type: String, enum: [ 'spam', 'useful', 'positive', 'negative', 'to do' ], required: true }, + createdAt: { type: Date, default: Date.now } +}) + +var replyValidator = [ + { validator: minTextValidator, msg: 'comments.reply-cannot-be-empty' }, + { validator: maxTextValidator, msg: 'comments.argument-limited' } +] + +/* + * Comment Reply Schema + */ + +var CommentReplySchema = new Schema({ + author: { type: ObjectId, required: true, ref: 'User' }, + text: { type: String, validate: replyValidator, required: true }, + createdAt: { type: Date, default: Date.now }, + editedAt: { type: Date } +}) + +function minTextValidator (text) { + return text.length +} + +function maxTextValidator (text) { + return text.length <= 4096 +} + +var commentValidator = [ + { validator: minTextValidator, msg: 'comments.cannot-be-empty' }, + { validator: maxTextValidator, msg: 'comments.argument-limited' } +] + +/** + * Reduces multiple line breaks to a single one + * + * @param {String} text + * @return {String} reduced string + * @api private + */ + +function reduceLB (text) { + return text.replace(/\n{3,}/g, '\n\n') +} + +/* + * Comment Schema + */ +var CommentSchema = new Schema({ + author: { type: ObjectId, required: true, ref: 'User' }, + text: { type: String, validate: commentValidator, trim: true, required: true, set: reduceLB }, + replies: [ CommentReplySchema ], + // Reference to the ObjectId of the Discussion Context + reference: { type: Schema.Types.Mixed, required: true }, + // Discussion Context + context: { type: String, required: true, enum: ['proposal', 'topic', 'clause', 'body', 'paragraph'] }, + // If the context is clause or body, we save a reference to the topic to get the Side Comments of a Topic + // in a straightforward way + topicId: { type: ObjectId }, + votes: [ Vote ], + score: { type: Number, default: 0 }, + flags: [ Flag ], + // The Administration flags for this comment + adminMarks: [{ type: String }], + createdAt: { type: Date, default: Date.now }, + editedAt: { type: Date } +}) + +CommentSchema.index({ createdAt: -1 }) +CommentSchema.index({ score: -1 }) +CommentSchema.index({ reference: -1, context: -1 }) + +CommentSchema.set('toObject', { getters: true }) +CommentSchema.set('toJSON', { getters: true }) + +/** + * Get `positive` votes + * + * @return {Array} voters + * @api public + */ + +CommentSchema.virtual('upvotes').get(function () { + return this.votes.filter(function (v) { + return v.value === 'positive' + }) +}) + +/** + * Get `negative` votes + * + * @return {Array} voters + * @api public + */ + +CommentSchema.virtual('downvotes').get(function () { + return this.votes.filter(function (v) { + return v.value === 'negative' + }) +}) + +/** + * Get `replies` count + * + * @return {Int} replies count + * @api public + */ + +CommentSchema.virtual('repliesCount').get(function () { + return this.replies.length +}) + +/** + * Vote Comment with provided user + * and voting value + * + * @param {User|ObjectId|String} user + * @param {String} value + * @param {Function} cb + * @api public + */ + +CommentSchema.methods.vote = function (user, value, cb) { + var vote = { author: user, value: value } + this.unvote(user) + this.votes.push(vote) + this.score = this.upvotes.length - this.downvotes.length + this.save(cb) +} + +CommentSchema.methods.voteOf = function voteOf (user) { + if (!user) return undefined + + const userId = user.get ? user.get('_id') : user + + return this.votes.find(function (vote) { + const authorId = vote.author.get ? vote.author.get('_id') : vote.author + return authorId.equals ? authorId.equals(userId) : authorId === userId + }) +} + +/** + * Unvote Comment from provided user + * + * @param {User|ObjectId|String} user + * @param {Function} cb + * @api public + */ + +CommentSchema.methods.unvote = function (user, cb) { + var votes = this.votes + var c = user.get ? user.get('_id') : user + + var voted = votes.filter(function (v) { + var a = v.author.get ? v.author.get('_id') : v.author + return a.equals ? a.equals(c) : a === c + }) + + if (voted.length > 0) { + voted.forEach(function (v) { + var removed = votes.id(v.id).remove() + log('Remove vote %j', removed) + }) + + this.score = this.upvotes.length - this.downvotes.length + } + + if (cb) this.save(cb) +} + +/** + * Flag Comment with provided user + * and flag value + * + * @param {User|ObjectId|String} user + * @param {String} value + * @param {Function} cb + * @api public + */ + +CommentSchema.methods.flag = function (user, value, cb) { + var c = user.get ? user.get('_id') : user + var flag = { author: c, value: value } + this.unflag(user) + this.flags.push(flag) + this.save(cb) +} + +CommentSchema.methods.mark = function (value, cb) { + const adminMarks = this.adminMarks + if(this.adminMarks){ + if(!this.adminMarks.find(m => m === value)) this.adminMarks.push(value) + } else { + this.adminMarks = [value] + } + this.save(cb) +} + + +CommentSchema.methods.unmark = function (value, cb) { + let aux = this.adminMarks.filter( flag => { + return flag != value + }) + this.adminMarks = aux + if (cb) this.save(cb) +} + +/** + * Unflag Comment from provided user + * + * @param {User|ObjectId|String} user + * @param {Function} cb + * @api public + */ + +CommentSchema.methods.unflag = function (user, cb) { + var flags = this.flags + var c = user.get ? user.get('_id') : user + + var flagged = flags.filter(function (v) { + var a = v.author.get ? v.author.get('_id') : v.author + return a.equals + ? a.equals(c) + : a === c + }) + + log('About to remove flags %j', flagged) + flagged.length && flagged.forEach(function (v) { + var removed = flags.id(v.id).remove() + log('Remove vote %j', removed) + }) + + if (cb) this.save(cb) +} + +module.exports = function initialize (conn) { + return conn.model('Comment', CommentSchema) +} diff --git a/ext/lib/admin/admin-comments/action-container.js b/ext/lib/admin/admin-comments/action-container.js new file mode 100644 index 000000000..4a74bfa09 --- /dev/null +++ b/ext/lib/admin/admin-comments/action-container.js @@ -0,0 +1,63 @@ +import React, { Component } from 'react' +import { Link } from 'react-router' + +import Vote from './actions/vote' +import Poll from './actions/poll' +import Cause from './actions/cause' +import Slider from './actions/slider' +import Hierarchy from './actions/hierarchy' + +export default class ActionContainer extends Component { + constructor(props) { + super(props) + this.state = { + topicCopy: this.props.topic, + shrinked: true, + } + this.toggleShrink = this.toggleShrink.bind(this); + } + + componentWillMount() { + this.state.topicCopy.closed = true + } + + toggleShrink() { + this.setState((state) => ({ + shrinked: !state.shrinked + })) + } + + render() { + let { topicCopy, shrinked } = this.state + return ( +
+ { + !shrinked && +
+ {(() => { + switch (topicCopy.action.method) { + case 'vote': + return + case 'poll': + return + case 'cause': + return + case 'slider': + return + case 'hierarchy': + return + } + })()} +
+ } +
+ { + shrinked ? +
▼ Mostrar los resultados ▼
+ :
▲ Ocultar los resultados ▲
+ } +
+
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/actions/cause.js b/ext/lib/admin/admin-comments/actions/cause.js new file mode 100644 index 000000000..5e6354873 --- /dev/null +++ b/ext/lib/admin/admin-comments/actions/cause.js @@ -0,0 +1,19 @@ +import React, { Component } from 'react' +import { Link } from 'react-router' + +export default class Cause extends Component { + constructor(props) { + super(props) + this.state = { + } + } + + render() { + let { topic } = this.props + return ( +
+ {topic.action.count} participantes brindaron su apoyo ♥ +
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/actions/hierarchy.js b/ext/lib/admin/admin-comments/actions/hierarchy.js new file mode 100644 index 000000000..7f3ee6e2a --- /dev/null +++ b/ext/lib/admin/admin-comments/actions/hierarchy.js @@ -0,0 +1,47 @@ +import React, { Component } from 'react' +import { Link } from 'react-router' + +export default class Hierarchy extends Component { + constructor(props) { + super(props) + this.state = { + + } + } + + render() { + let { topic } = this.props + return ( +
+
+ Han participado {topic.action.count} usuarios +
+ + + + + + + + + + + + { + topic.action.results.map((option, i) => + + + + + ) + } + +
Tipo de acción: Herarquia
OpciónPosición
+ {option.value} + + {option.position}° +
+
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/actions/poll.js b/ext/lib/admin/admin-comments/actions/poll.js new file mode 100644 index 000000000..44ad0d382 --- /dev/null +++ b/ext/lib/admin/admin-comments/actions/poll.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react' +import { Link } from 'react-router' + +export default class Poll extends Component { + constructor(props) { + super(props) + this.state = {} + } + + + render() { + let { topic } = this.props + return ( +
+
+ Han participado {topic.action.count} usuarios +
+ + + + + + + + + + + + + { + topic.action.results.map((option, i) => + + + + + + ) + } + +
Tipo de acción: Encuesta
OpciónVotosPorc.
+ {option.value} + + {option.votes} + + {option.percentage} % +
+
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/actions/slider.js b/ext/lib/admin/admin-comments/actions/slider.js new file mode 100644 index 000000000..c9c1b7535 --- /dev/null +++ b/ext/lib/admin/admin-comments/actions/slider.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react' +import { Link } from 'react-router' + +export default class Slider extends Component { + constructor(props) { + super(props) + this.state = { + options: [ + 'Totalmente en contra', + 'Muy en contra', + 'En contra', + 'Un poco en contra', + 'A favor', + 'Un poco a favor', + 'A favor', + 'Muy a favor', + 'Totalmente a favor', + ] + } + } + + render() { + let { topic } = this.props + let { options } = this.state + return ( +
+
+ Han participado {topic.action.count} usuarios +
+ + + + + + + + + + + + + { + options.map( (option, i) => + + + + + + + ) + } + + +
Tipo de accion: Rango
OpciónVotosPorc.
+ {option} + + {topic.action.results[i].votes} + + {topic.action.results[i].percentage} % +
+
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/actions/vote.js b/ext/lib/admin/admin-comments/actions/vote.js new file mode 100644 index 000000000..109092642 --- /dev/null +++ b/ext/lib/admin/admin-comments/actions/vote.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react' +import { Link } from 'react-router' + +export default class Vote extends Component { + constructor(props) { + super(props) + this.state = { + options: { + 'positive' : 'Votos a favor', + 'neutral' : 'Votos en abstención', + 'negative' : 'Votos en contra', + + } + } + } + + + render() { + let { topic } = this.props + let { options } = this.state + return ( +
+
+ Han participado {topic.action.count} usuarios +
+ + + + + + + + + + + + + { + topic.action.results.map((option, i) => + + + + + + ) + } + +
Tipo de acción: Voto
OpciónVotosPorc.
+ {options[option.value]} + + {option.votes} + + {option.percentage} % +
+
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/comment-container.js b/ext/lib/admin/admin-comments/comment-container.js new file mode 100644 index 000000000..c577ba3f4 --- /dev/null +++ b/ext/lib/admin/admin-comments/comment-container.js @@ -0,0 +1,398 @@ +import React, { Component } from 'react' +import t from 't-component' +import 'whatwg-fetch' +import urlBuilder from 'lib/url-builder' +import userConnector from 'lib/site/connectors/user' + +class CommentContainer extends Component { + constructor (props) { + super(props) + this.state = { + shrinked: true, + showReply: false, + answeredByOficial: false, + textReply: null, + flagedByOficial: false, + meFlaggedIt: false, + } + this.recheck = this.recheck.bind(this); + this.toggleShrink = this.toggleShrink.bind(this); + this.toggleReply = this.toggleReply.bind(this); + this.closeReply = this.closeReply.bind(this); + this.checkIfAnswered = this.checkIfAnswered.bind(this); + this.checkIfFlaged = this.checkIfFlaged.bind(this); + this.submitReply = this.submitReply.bind(this); + this.markFlag = this.markFlag.bind(this); + this.unmarkFlag = this.unmarkFlag.bind(this); + this.deleteComment = this.deleteComment.bind(this); + this.deleteReply = this.deleteReply.bind(this); + this.isOfficial = this.isOfficial.bind(this); + this.didIFlaggedIt = this.didIFlaggedIt.bind(this); + this.markComment = this.markComment.bind(this); + this.unmarkComment = this.unmarkComment.bind(this); + } + + componentDidMount(){ + this.checkIfAnswered() + this.checkIfFlaged() + this.didIFlaggedIt() + } + + recheck(){ + this.checkIfAnswered() + this.checkIfFlaged() + this.didIFlaggedIt() + } + + getClassNameContainer(){ + if(this.state.shrinked) return 'comment-container clearfix shrink' + return 'comment-container clearfix' + } + + toggleShrink() { + this.setState((state) => ({ + shrinked: !state.shrinked + })) + } + + toggleReply(e) { + e.preventDefault(); + this.setState((state) => ({ + shrinked: false, + showReply: !state.showReply + })) + } + closeReply(e) { + this.setState((state) => ({ + showReply: false + })) + } + + handleTextChange = (evt) => { + const text = evt.currentTarget.value || '' + this.setState({ + textReply: text + }) + } + + submitReply(){ + fetch(`/api/v2/comments/${this.props.comment.id}/reply`, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + text: this.state.textReply + }) + }).then(res => res.ok && res.json()) + .then(res => { + this.props.showNotifyChanges('Respuesta guardada') + this.props.updateAll() + }) + .catch(err => { + console.error(err) + }) + // url: `/api/v2/comments/${data.id}/reply`, + // method: 'POST', + // force: true, + // body: JSON.stringify({ + + } + + markFlag(){ + fetch(`/api/v2/comments/${this.props.comment.id}/flag`, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }).then(res => res.json()) + .then(res => { + if(res.error && res.error.code === 'NO_AUTO_FLAG'){ + this.props.showNotifyChanges('No podes marcar tu propio comentario como Spam') + } else { + this.props.showNotifyChanges('Comentario marcado como Spam') + this.props.updateAll() + } + }) + .catch(err => { + console.error(err) + }) + // url: `/api/v2/comments/${data.id}/reply`, + // method: 'POST', + // force: true, + // body: JSON.stringify({ + + } + deleteComment(){ + fetch(`/api/v2/comments/${this.props.comment.id}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }).then(res => res.ok && res.json()) + .then(res => { + this.props.showNotifyChanges('Comentario eliminado') + this.props.updateAll() + }) + .catch(err => { + console.error(err) + }) + // url: `/api/v2/comments/${data.id}/reply`, + // method: 'POST', + // force: true, + // body: JSON.stringify({ + + } + deleteReply(replyId){ + fetch(`/api/v2/comments/${this.props.comment.id}/replies/${replyId}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }).then(res => res.ok && res.json()) + .then(res => { + this.props.showNotifyChanges('Respuesta eliminada') + this.props.updateAll() + }) + .catch(err => { + console.error(err) + }) + // url: `/api/v2/comments/${data.id}/reply`, + // method: 'POST', + // force: true, + // body: JSON.stringify({ + + } + + unmarkFlag(){ + fetch(`/api/v2/comments/${this.props.comment.id}/unflag`, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }).then(res => res.ok && res.json()) + .then(res => { + this.props.showNotifyChanges('Comentario dejo de marcarse como spam') + this.props.updateAll() + }) + .catch(err => { + console.error(err) + }) + // url: `/api/v2/comments/${data.id}/reply`, + // method: 'POST', + // force: true, + // body: JSON.stringify({ + + } + + checkIfAnswered() { + let answered = false + this.props.admins.forEach(a => { + if(a.user._id == this.props.comment.author.id) answered = true + }) + if(!answered){ + let replyAuthorsId = this.props.comment.replies.map(reply => { + return reply.author.id + }) + this.props.admins.forEach(a => { + if(replyAuthorsId.includes(a.user._id)) answered = true + }) + } + this.setState({ + answeredByOficial: answered + }) + } + + checkIfFlaged() { + let flaged = false + let flaggersIds = this.props.comment.flags.map(flag => { + return flag.author + }) + this.props.admins.forEach(a => { + if(flaggersIds.includes(a.user._id)) flaged = true + }) + this.setState({ + flagedByOficial: flaged + }) + } + + didIFlaggedIt() { + let flaged = false + let flaggersIds = this.props.comment.flags.map(flag => { + return flag.author + }) + if(flaggersIds.includes(this.props.user.state.value.id)){ + flaged = true + } + this.setState({ + meFlaggedIt: flaged + }) + } + + isOfficial(author){ + return this.props.admins.find(a => { + return a.user._id == author.id + }) + } + +markComment(mark){ + fetch(`/ext/api/admin-stats/comments/${this.props.comment.id}/mark`, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + mark: mark + }) + }).then(res => res.ok && res.json()) + .then(res => { + this.props.showNotifyChanges(`El comentario se marcó como ${mark}`) + this.props.updateAll() + }) + .catch(err => { + console.error(err) + }) + // url: `/api/v2/comments/${data.id}/reply`, + // method: 'POST', + // force: true, + // body: JSON.stringify({ + + } + unmarkComment(mark){ + fetch(`/ext/api/admin-stats/comments/${this.props.comment.id}/unmark`, { + method: 'POST', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + mark: mark + }) + }).then(res => res.ok && res.json()) + .then(res => { + this.props.showNotifyChanges(`Se quitó el marcado #${mark} del comentario`) + this.props.updateAll() + }) + .catch(err => { + console.error(err) + }) + // url: `/api/v2/comments/${data.id}/reply`, + // method: 'POST', + // force: true, + // body: JSON.stringify({ + + } + + + render() { + let { comment, user, availableMarks } = this.props + return ( +
+
+ + +
+ { + this.state.showReply && +
+ + + +
+ } +
+
+ {comment.author.fullName}/ +
+
+

{comment.author.displayName} + { + this.isOfficial(comment.author) &&   ★ Cuenta oficial + } +    + {comment.text}

+
+
+
+ ↵ Responder + { + this.state.meFlaggedIt ? + ⚑ Quitar spam + : ⚑ Marcar como spam + + } + ✗ Eliminar +
+ { + comment.replies.map( r => ( +
+

{r.author.displayName} + { + this.isOfficial(r.author) &&   ★ Cuenta oficial + } +   {r.text} - this.deleteReply(r._id)}>✗ Eliminar

+
+ ) + ) + } +
+

Marcar comentario como...

+ { + availableMarks.map( m => { + if(comment.adminMarks.includes(m)){ + return this.unmarkComment(m)}>#{m} + } + return this.markComment(m)}>#{m} + }) + } +
+ +
+
+ Publicado el {(new Date(comment.createdAt)).toLocaleString()} - {comment.replies.length} Respuestas - {comment.score} Puntos   + { + this.state.answeredByOficial ? + ✔ Contestado por un oficial + : ✖ Sin contestar por un oficial + + } + { + this.state.flagedByOficial && + ⚑ Marcado SPAM por un oficial + } + { + comment.adminMarks && comment.adminMarks.length > 0 && ( +
+
+ { + comment.adminMarks.map( m => #{m}) + } +
+ ) + } +
+
+ ) + } +} + +export default userConnector(CommentContainer) \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/component.js b/ext/lib/admin/admin-comments/component.js new file mode 100644 index 000000000..a71380efb --- /dev/null +++ b/ext/lib/admin/admin-comments/component.js @@ -0,0 +1,191 @@ +import React, { Component } from 'react' +import t from 't-component' +import 'whatwg-fetch' +import urlBuilder from 'lib/url-builder' +import ForumTable from './forum-table' +import TopicTable from './topic-table' + +export default class AdminComments extends Component { + constructor (props) { + super(props) + this.state = { + isFetching: true, + topics: [], + comments: [], + error: '', + admins: [], + availableAdmins: [], + availableRoles: ['owner','admin','collaborator','author','participant','moderator'], + officialRoles: ['owner','admin','collaborator','author','moderator'], + availableMarks: ['Destacado','Derivar','No aplica','Alerta','Pregunta'], + notifyChange: false, + notifyMessage: '' + } + this.toggleNotifyChanges = this.toggleNotifyChanges.bind(this); + this.showNotifyChanges = this.showNotifyChanges.bind(this); + this.updateAll = this.updateAll.bind(this); + + } + + componentDidMount () { + this.fetchTopics() + } + + updateAll(){ + this.setState( (state) => ({ + isFetching: true + }), () => { + this.fetchTopics() + }) + } + + toggleNotifyChanges() { + this.setState( (state) => ({ + notifyChange: !state.notifyChange + })) + } + + showNotifyChanges(msg) { + setTimeout( () => { + this.setState( (state) => ({ + notifyChange: false + }) + ) + },3000) + this.setState( (state) => ({ + notifyMessage: msg, + notifyChange: true + })) + } + + fetchTopics () { + fetch('/api/v2/topics?forum=' + this.props.forum.id, { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(res => res.ok && res.json()) + .then(topics => { + if (!topics.results.topics) return console.log('no topics') + this.setState({topics: topics.results.topics, error: ''}, () => { + this.fetchCommentsTopics() + }) + }) + .catch(err => { + console.error(err) + this.setState({error: 'fetch topics error'}) + }) + } + + fetchCommentsTopics () { + // if(this.state.topics.length === 0) return + let promisesArr = this.state.topics.map( topic => { + return fetch('/ext/api/admin-stats/comments?topicId=' + topic.id, { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }) + }) + let topicKeys = this.state.topics.map( topic => { + return topic.id + }) + Promise.all(promisesArr) + .then(responses => Promise.all(responses.map(res => res.json()))) + .then(responses => { + let comments = {} + responses.forEach((res,index) => { + comments[topicKeys[index]] = res.results.comments + }); + this.setState({ + comments: comments + }, () => { + this.fetchOtherAdmins() + }) + }) + .catch(err => { + console.error(err) + this.setState({error: 'fetch comments error'}) + }) + } + + fetchOtherAdmins () { + fetch(`/ext/api/admin-stats/forum/${this.props.forum.id}/permissions`, { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(res => res.ok && res.json()) + .then(res => { + if (!res) return console.log('no admins') + let admins = res.filter(a => { + return this.state.officialRoles.includes(a.role) + }) + let isOwnerIncluded = admins.find( a => { + return a.user._id == this.props.forum.owner.id + }) + if(!isOwnerIncluded) { + let owner = this.props.forum.owner + owner._id = this.props.forum.owner.id + admins.push({ + role: 'owner', + user: owner + }) + } + this.setState({ + availableAdmins: res, + admins: admins, + isFetching: false, + }) + }) + .catch(err => { + console.error(err) + this.setState({error: 'fetch admins error'}) + }) + } + + + render() { + let { forum } = this.props + let { isFetching, topics, comments, admins , officialRoles, notifyMessage, notifyChange, availableMarks } = this.state + return ( +
+ + Comentarios - { t('admin-comments.dowload-as-csv') } + + { + isFetching ? +
+ Cargando... +
+ : ( +
+ + { + topics.map(t => + + ) + } +
+ ) + } + { + notifyChange && +
+ + {notifyMessage} +
+ } +
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/forum-table.js b/ext/lib/admin/admin-comments/forum-table.js new file mode 100644 index 000000000..1044e96f9 --- /dev/null +++ b/ext/lib/admin/admin-comments/forum-table.js @@ -0,0 +1,207 @@ +import React, { Component } from 'react' +import t from 't-component' +import 'whatwg-fetch' +import urlBuilder from 'lib/url-builder' + +export default class ForumTable extends Component { + constructor(props) { + super(props) + this.state = { + totalCommentsAcrossTopics: 0, + totalCommentsAndRepliesAcrossTopics: 0, + averageCommentsPerTopic: 0, + totalSpamComments: 0, + totalWithoutOfficialReply: 0, + uniqueParticipants: 0, + marksCount: [], + } + } + + componentWillMount() { + this.calculateTotals() + this.calculateAverage() + this.calculateSpam() + this.calculateWithoutOfficialReply() + this.calculateMarkers() + } + + calculateTotals() { + let totalCommentsAcrossTopics = 0 + let totalCommentsAndRepliesAcrossTopics = 0 + Object.keys(this.props.comments).forEach(topicId => { + let repliesCount = 0 + this.props.comments[topicId].forEach(c => { + repliesCount += c.repliesCount + }) + totalCommentsAcrossTopics += this.props.comments[topicId].length + totalCommentsAndRepliesAcrossTopics += this.props.comments[topicId].length + repliesCount + }) + this.setState({ + totalCommentsAcrossTopics, + totalCommentsAndRepliesAcrossTopics + }) + } + + calculateAverage() { + let averageCommentsPerTopic = 0 + let totalPrimaryComments = 0 + Object.keys(this.props.comments).forEach(topicId => { + totalPrimaryComments += this.props.comments[topicId].length + }) + averageCommentsPerTopic = (totalPrimaryComments / this.props.topics.length) + this.setState({ + averageCommentsPerTopic + }) + } + + calculateSpam() { + let totalSpamComments = 0 + Object.keys(this.props.comments).forEach(topicId => { + this.props.comments[topicId].forEach(c => { + if (c.flags.length > 0) totalSpamComments += 1 + }) + }) + this.setState({ + totalSpamComments + }) + } + calculateWithoutOfficialReply() { + let totalComments = 0 + Object.keys(this.props.comments).forEach(topicId => { + totalComments += this.props.comments[topicId].length + }) + let totalWithOfficialReply = 0 + let adminsIds = this.props.admins.map(a => a.user._id) + let uniqueParticipants = [] + Object.keys(this.props.comments).forEach(topicId => { + this.props.comments[topicId].forEach(c => { + let foundAtLeastOneOfficial = false + // ---------- + if (!uniqueParticipants.includes(c.author.id) && !adminsIds.includes(c.author.id)) { uniqueParticipants.push(c.author.id) } + c.replies.forEach(r => { + if (!uniqueParticipants.includes(r.author.id) && !adminsIds.includes(r.author.id)) { uniqueParticipants.push(r.author.id) } + }) + // ---------- + if (adminsIds.includes(c.author.id)) { foundAtLeastOneOfficial = true } + else { + c.replies.forEach(r => { + if (adminsIds.includes(r.author.id)) { foundAtLeastOneOfficial = true } + }) + } + if (foundAtLeastOneOfficial) totalWithOfficialReply += 1 + }) + }) + this.setState({ + totalWithoutOfficialReply: totalComments - totalWithOfficialReply, + uniqueParticipants: uniqueParticipants.length + }) + } + calculateMarkers() { + let marksCount = this.state.marksCount + this.props.availableMarks.forEach(m => { + let markCount = 0; + Object.keys(this.props.comments).forEach(topicId => { + this.props.comments[topicId].forEach(c => { + if (c.adminMarks.includes(m)) markCount += 1 + }) + }) + if (markCount > 0) { + let aux = {} + aux.name = m + aux.count = markCount + marksCount.push(aux) + } + }) + this.setState({ + marksCount: marksCount + }) + } + + render() { + let { forum, topics, admins, comments } = this.props + return ( +
+

Consulta

+

{forum.title}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + this.state.marksCount.map(markCount => ( + + + + + ) + ) + } + +
Estadisticas generales de la consulta en general
+ Cantidad de comentarios y respuestas en total + + {this.state.totalCommentsAndRepliesAcrossTopics} +
+ Cantidad de comentarios primarios + + {this.state.totalCommentsAcrossTopics} +
+ Cantidad participantes únicos + + {this.state.uniqueParticipants} +
+ Media de comentarios por eje + + {this.state.averageCommentsPerTopic} +
+ Comentarios marcados como ⚑ SPAM + + {this.state.totalSpamComments} +
+ Comentarios sin respuestas por oficiales + + {this.state.totalWithoutOfficialReply} +
+ Porcentaje de comentarios atendidos + + {Math.ceil(((this.state.totalCommentsAcrossTopics - this.state.totalWithoutOfficialReply) / this.state.totalCommentsAcrossTopics) * 100)} % +
+ Comentarios marcados como #{markCount.name} + + {markCount.count} +
+
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/styles.styl b/ext/lib/admin/admin-comments/styles.styl new file mode 100644 index 000000000..96c0d3514 --- /dev/null +++ b/ext/lib/admin/admin-comments/styles.styl @@ -0,0 +1,219 @@ +.forum-subtitle, .topic-subtitle + font-weight: 300; + margin-bottom: 0; +.forum-title, .topic-title + margin-top: 0; +.topic-name-container + font-size: 25px; + font-weight: 800; + // color: #FFF + border-radius: 0px; + // background: #0695d6; + padding: 10px 0 0; + margin-top: 30px; + border-top: 1px solid #cacaca + .topic-subtitle + font-size: 20px + .topic-title + font-size: 25px + // color: #FFF +.action-topic-container + // padding: 10px; + // border: 1px solid #ececec + margin-bottom: 25px; + .toggle-title + font-weight:300; + text-align: center; + font-size: 20px + &:hover + cursor: pointer + .ext-topic-layout + @import '../../site/topic-layout/topic-article/styles.styl' + +.comment-container + border: 1px solid #ececec + border-bottom: 0 + padding: 10px + padding-right: 20px + position: relative; + .btn-toggle-shrink + position:absolute + right: 0 + top: 0 + border-radius: 0 + &.shrink + max-height: 40px + overflow-y: hidden + .overflow-effect + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-shadow: inset 0px -44px 11px -24px #fff; + &:hover + cursor: pointer + &:last-child + border-bottom: 1px solid #ececec; + .media + margin-top: 0; + button.btn-toggle-shrink + z-index: 10 + background-color: #ececec + img.the-avatar + width: 20px + border-width: 15px + border-radius: 15px + p.comment-text + font-size: 13px; + font-weight: 400; + line-height: normal; + margin-bottom: 0; + .reply-container + border-left: 1px solid #ececec + padding-left: 10px + padding-top: 5px; + margin-left: 30px + padding-right: 10px + p.reply-text + font-size: 11px + font-weight: 300 + line-height: normal; + margin-bottom: 0; + a.delete-anchor + color: #da4848 + &:hover + cursor: pointer + .actions + margin-left: 30px + padding: 5px 0; + .anchor + border: 1px solid #CACACA; + padding: 2px 5px + margin: 5px + font-size: 12px + border-radius: 3px + &:first-child + margin-left: 0 + &:hover + cursor: pointer + color: #53599e + border-color: #53599e + .markers + margin-left: 30px + padding: 5px 0; + p + margin: 5px 0 5px + font-size: 15px + .marker-anchor + padding: 2px 5px + margin: 5px + font-size: 12px + border-radius: 3px + &:first-child + margin-left: 0 + &.mark-anchor + border: 1px solid #CACACA; + color: #333; + &:hover + background-color: #53599e + border-color: #53599e; + cursor: pointer + color: #fff + &.unmark-anchor + border: 1px solid #53599e; + background-color: #53599e + padding: 2px 5px + margin: 5px + font-size: 12px + border-radius: 3px + color: #FFF; + &:hover + background-color: #ffffff + border-color: #333333; + cursor: pointer + color: #333333 + +.general-stats-container + // background-color: #fff; + // border-radius: 6px; + // box-shadow: 0 2px 3px rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.1); + // display: block; + // padding: 1rem; + margin: 20px 0; + table.table + margin:0; + .badge-mark + background-color: #53599e; + color: #fff; + padding: 2px 5px; + border-radius: 2px; + font-size: 13px + .bg-light + background-color: #f3f3f3 +.info-comment + font-size: 10px; + padding: 8px 5px; + font-weight: 400; + background-color: #ececec; + // color: #696969; + margin-bottom: 10px; + .badge-answered + background-color: #43aa48; + color: #fff; + padding: 2px 5px; + border-radius: 2px; + font-weight: 500; + margin: 0 2px; + .badge-marker + background-color: #53599e; + color: #fff; + padding: 2px 5px; + border-radius: 2px; + font-weight: 500; + margin: 0 2px; + .badge-not-answered + background-color: #da4848; + color: #fff; + padding: 2px 5px; + border-radius: 2px; + font-weight: 500; + margin: 0 2px; +.reply-comment-container + float: right; + max-width: 200px; + width: 100%; + margin-left: 15px; + margin-right: 15px; + textarea + margin-bottom: 5px; + font-size: 11px; + font-weight: 400 + border-radius: 0 + resize: vertical +.notify-change + position: fixed; + bottom: 0; + left: 0; + width: 100%; + background: #0695d6; + padding: 15px 8px 30px 8px; + text-align: center; + border-top: 1px solid #FFF; + z-index: 100; + color: #FFF; + line-height: normal; + a + color: #FFF + font-weight: 800 + &:hover + color: yellow + +.topic-action-container + background-color: #fff; + border-radius: 6px; + box-shadow: 0 2px 3px rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.1); + display: block; + padding: 1rem; + margin: 20px 0; + text-align: center; \ No newline at end of file diff --git a/ext/lib/admin/admin-comments/topic-table.js b/ext/lib/admin/admin-comments/topic-table.js new file mode 100644 index 000000000..d7f51e8dd --- /dev/null +++ b/ext/lib/admin/admin-comments/topic-table.js @@ -0,0 +1,212 @@ +import React, { Component } from 'react' +import { Link } from 'react-router' +import t from 't-component' +import 'whatwg-fetch' +import urlBuilder from 'lib/url-builder' +import CommentContainer from './comment-container' +import ActionContainer from './action-container' + + +export default class TopicTable extends Component { + constructor(props) { + super(props) + this.state = { + totalCommentsAcrossTopics: 0, + totalCommentsAndRepliesAcrossTopics: 0, + averageCommentsPerTopic: 0, + totalSpamComments: 0, + totalWithoutOfficialReply: 0, + uniqueParticipants: 0, + marksCount: [], + } + } + + componentWillMount() { + this.calculateTotals() + this.calculateAverage() + this.calculateSpam() + this.calculateWithoutOfficialReply() + this.calculateMarkers() + } + + calculateTotals() { + let totalCommentsAcrossTopics = 0 + let totalCommentsAndRepliesAcrossTopics = 0 + let repliesCount = 0 + this.props.comments.forEach(c => { + repliesCount += c.repliesCount + }) + totalCommentsAcrossTopics += this.props.comments.length + totalCommentsAndRepliesAcrossTopics += this.props.comments.length + repliesCount + this.setState({ + totalCommentsAcrossTopics, + totalCommentsAndRepliesAcrossTopics + }) + } + + calculateAverage() { + let averageCommentsPerTopic = 0 + let totalPrimaryComments = 0 + totalPrimaryComments += this.props.comments.length + averageCommentsPerTopic = (totalPrimaryComments / this.props.topic.length) + this.setState({ + averageCommentsPerTopic + }) + } + calculateSpam() { + let totalSpamComments = 0 + this.props.comments.forEach(c => { + if (c.flags.length > 0) totalSpamComments += 1 + }) + this.setState({ + totalSpamComments + }) + } + calculateWithoutOfficialReply() { + let totalComments = this.props.comments.length + let totalWithOfficialReply = 0 + let adminsIds = this.props.admins.map(a => a.user._id) + let uniqueParticipants = [] + this.props.comments.forEach(c => { + let foundAtLeastOneOfficial = false + if (!uniqueParticipants.includes(c.author.id) && !adminsIds.includes(c.author.id)) { uniqueParticipants.push(c.author.id) } + c.replies.forEach(r => { + if (!uniqueParticipants.includes(r.author.id) && !adminsIds.includes(r.author.id)) { uniqueParticipants.push(r.author.id) } + }) + // -------- + if (adminsIds.includes(c.author.id)) { foundAtLeastOneOfficial = true } + else { + c.replies.forEach(r => { + if (adminsIds.includes(r.author.id)) { foundAtLeastOneOfficial = true } + if (!uniqueParticipants.includes(r.author.id) && !adminsIds.includes(r.author.id)) { uniqueParticipants.push(r.author.id) } + }) + } + // -------- + if (foundAtLeastOneOfficial) totalWithOfficialReply += 1 + }) + this.setState({ + totalWithoutOfficialReply: totalComments - totalWithOfficialReply, + uniqueParticipants: uniqueParticipants.length + }) + } + calculateMarkers() { + let marksCount = this.state.marksCount + this.props.availableMarks.forEach(m => { + let markCount = 0; + this.props.comments.forEach(c => { + if (c.adminMarks.includes(m)) markCount += 1 + }) + if (markCount > 0) { + let aux = {} + aux.name = m + aux.count = markCount + marksCount.push(aux) + } + }) + this.setState({ + marksCount: marksCount + }) + } + + render() { + let { forum, topic, admins, comments, showNotifyChanges, updateAll, availableMarks } = this.props + let { marksCount } = this.state + return ( +
+
+
Eje de la consulta
+ +

{topic.mediaTitle}

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + marksCount.map(markCount => ( + + + + + ) + ) + } + +
Estadisticas generales del eje
+ Comentarios y respuestas en total + + {this.state.totalCommentsAndRepliesAcrossTopics} +
+ Comentarios primarios + + {this.state.totalCommentsAcrossTopics} +
+ Cantidad participantes únicos + + {this.state.uniqueParticipants} +
+ Comentarios marcados como ⚑ SPAM + + {this.state.totalSpamComments} +
+ Comentarios sin respuestas por oficiales + + {this.state.totalWithoutOfficialReply} +
+ Porcentaje de comentarios atendidos + + {Math.ceil(((this.state.totalCommentsAcrossTopics - this.state.totalWithoutOfficialReply) / this.state.totalCommentsAcrossTopics) * 100)} % +
+ Comentarios marcados como #{markCount.name} + + {markCount.count} +
+
+
+ {topic.action && topic.action.method != '' && + + } +
+
+ { + comments.map(c => ( + + ) + ) + } +
+
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/admin/boot/boot.js b/ext/lib/admin/boot/boot.js index 1e8397c79..dc975d73b 100644 --- a/ext/lib/admin/boot/boot.js +++ b/ext/lib/admin/boot/boot.js @@ -3,3 +3,4 @@ import './overrides' require('ext/lib/boot/routes')() require('lib/admin/boot/boot') + diff --git a/ext/lib/admin/boot/overrides.js b/ext/lib/admin/boot/overrides.js index 0585bdc44..f3b5f6e2d 100644 --- a/ext/lib/admin/boot/overrides.js +++ b/ext/lib/admin/boot/overrides.js @@ -1,4 +1,7 @@ import * as EditForum from 'lib/admin/admin-edit-forum/component' import * as EditForumExt from 'ext/lib/admin/admin-edit-forum/component' +import * as AdminComments from 'lib/admin/admin-comments/component' +import * as AdminCommentsExt from 'ext/lib/admin/admin-comments/component' -EditForum.default = EditForumExt.default \ No newline at end of file +EditForum.default = EditForumExt.default +AdminComments.default = AdminCommentsExt.default diff --git a/ext/lib/api/admin-stats.js b/ext/lib/api/admin-stats.js new file mode 100644 index 000000000..2e91ff914 --- /dev/null +++ b/ext/lib/api/admin-stats.js @@ -0,0 +1,94 @@ +const express = require('express') +const validate = require('lib/api-v2/validate') +const middlewares = require('lib/api-v2/middlewares') +var forumMiddlewares = require('lib/middlewares/forum-middlewares') +var privileges = require('lib/privileges/forum') + +const api = require('lib/api-v2/db-api') +var apiv1 = require('lib/db-api') +var utils = require('lib/utils') +var expose = utils.expose +var restrict = utils.restrict + +const app = module.exports = express.Router() + +app.get('/comments', +middlewares.topics.findByTopicId, +middlewares.forums.findFromTopic, +middlewares.forums.privileges.canEdit, +function getComments (req, res, next) { + Promise.all([ + api.comments.adminlist({ + user: req.user, + topicId: req.query.topicId, + limit: 100000, + page: 1, + sort: '-createdAt' + }), + api.comments.listCount(req.query) + ]).then((results) => { + res.status(200).json({ + status: 200, + results: { + comments: results[0] + } + }) + }).catch(next) +}) + +app.get('/forum/:id/permissions', + middlewares.forums.findById, + middlewares.forums.privileges.canEdit, + function getPermissions (req, res, next) { + api.forums.find({ deletedAt: null, _id: req.params.id }) + .findOne() + .select('permissions') + .populate('permissions.user') + .exec() + .then((result) => { + res.status(200).json(result.permissions) + }).catch(next) + } +) + +app.post('/comments/:id/mark', +middlewares.users.restrict, +middlewares.comments.findById, +middlewares.topics.findFromComment, +middlewares.forums.findFromTopic, +middlewares.topics.privileges.canEdit, +function postCommentsMark (req, res, next) { + api.comments.mark({ + id: req.params.id, + user: req.user, + mark: req.body.mark + }).then((comment) => { + res.status(200).json({ + status: 200, + results: { + comment: comment + } + }) + }).catch(next) +}) + +app.post('/comments/:id/unmark', +middlewares.users.restrict, +middlewares.comments.findById, +middlewares.topics.findFromComment, +middlewares.forums.findFromTopic, +middlewares.topics.privileges.canComment, +function postCommentsMark (req, res, next) { + api.comments.unmark({ + id: req.params.id, + user: req.user, + mark: req.body.mark + }).then((comment) => { + res.status(200).json({ + status: 200, + results: { + comment: comment + } + }) + }).catch(next) +}) \ No newline at end of file diff --git a/ext/lib/api/index.js b/ext/lib/api/index.js index 07f1064b4..b9b90ac6b 100644 --- a/ext/lib/api/index.js +++ b/ext/lib/api/index.js @@ -11,6 +11,8 @@ if (process.env.CUSTOM_SIGNIN) app.use('/ext/auth/miargentina', require('./miarg app.use('/ext/api/forum', require('./forum')) app.use('/ext/api/filter', require('./filter')) app.use('/ext/api/search', require('./search')) +app.use('/ext/api/admin-stats', require('./admin-stats')) +app.use('/ext/api/stats', require('./stats')) app.use(function validationErrorHandler (err, req, res, next) { if (res.headersSent) return next(err) diff --git a/ext/lib/api/stats.js b/ext/lib/api/stats.js new file mode 100644 index 000000000..fb3657bc7 --- /dev/null +++ b/ext/lib/api/stats.js @@ -0,0 +1,225 @@ +const express = require('express') +// const validate = require('lib/api-v2/validate') +// const middlewares = require('lib/api-v2/middlewares') +// var forumMiddlewares = require('lib/middlewares/forum-middlewares') +// var privileges = require('lib/privileges/forum') + +// const api = require('lib/api-v2/db-api') +// var apiv1 = require('lib/db-api') +// var utils = require('lib/utils') +// var expose = utils.expose +// var restrict = utils.restrict +var utils = require('lib/utils') + +var models = require('lib/models') +var Forum = models.Forum +var Topic = models.Topic +var Comment = models.Comment + + +const app = module.exports = express.Router() + +app.get('/forum/:forumName', + function getAllForums(req, res, next) { + Forum + .findOne({ name: req.params.forumName, visibility: 'public', "extra.hidden": false }) + .populate('owner') + .exec() + .then((forum) => { + const legitRoles = ['owner', 'admin', 'collaborator', 'author', 'moderator'] + let officialAdmins = forum.permissions.filter(admin => { + return legitRoles.includes(admin.role) + }) + let isOwnerIncluded = officialAdmins.find(admin => { + return admin.user == forum.owner.id + }) + if (!isOwnerIncluded) { + officialAdmins.push({ + role: 'owner', + user: forum.owner.id + }) + } + req.forum = forum + req.officialRoles = officialAdmins + next() + }).catch(next) + }, + function countAllComments(req, res, next) { + Topic.find({ forum: req.forum._id }).exec() + .then(forumTopics => { + let topicIds = [] + forumTopics.forEach(topic => { + topicIds.push(topic._id) + }) + let commentsTopicPromises = topicIds.map(topic => { + return Comment.find({ reference: topic + '' }).exec() + }); + Promise.all(commentsTopicPromises) + .then(commentsTopic => { + const mergeArrays = (accumulator, currentValue) => accumulator.concat(currentValue) + let allComments = commentsTopic.reduce(mergeArrays, []) + let totalWithOfficialReply = 0; + const allOfficials = req.officialRoles.map(official => official.user.toString()) + let uniqueParticipants = [] + allComments.forEach(comment => { + let foundAtLeastOneOfficial = false + // ------ + if (!uniqueParticipants.includes(comment.author.toString()) + && !allOfficials.includes(comment.author.toString())) { uniqueParticipants.push(comment.author.toString()) } + comment.replies.forEach(reply => { + if (!uniqueParticipants.includes(reply.author.toString()) + && !allOfficials.includes(reply.author.toString())) { uniqueParticipants.push(reply.author.toString()) } + }) + // ------ + if (allOfficials.includes(comment.author.toString())) foundAtLeastOneOfficial = true + else { + comment.replies.forEach(reply => { + if (allOfficials.includes(reply.author.toString())) foundAtLeastOneOfficial = true + }) + } + if (foundAtLeastOneOfficial) totalWithOfficialReply += 1 + }) + res.status(200).json({ + totalWithOfficialReply: totalWithOfficialReply, + totalComments: allComments.length, + uniqueParticipants: uniqueParticipants.length + }) + }) + }).catch(next) + } +) + +app.get('/forums', + function getAllForums(req, res, next) { + Forum + .find({ deletedAt: null, visibility: 'public', "extra.hidden": false }) + .lean() + .exec() + .then((forums) => { + let forumsCopy = [] + const legitRoles = ['owner', 'admin', 'collaborator', 'author', 'moderator'] + forums.forEach(forum => { + let forumCopy = forum + let officialAdmins = forum.permissions.filter(admin => { + return legitRoles.includes(admin.role) + }) + let isOwnerIncluded = officialAdmins.find(admin => { + return admin.user.toString() == forum.owner.id.toString() + }) + if (!isOwnerIncluded) { + officialAdmins.push({ + role: 'owner', + user: forum.owner.toString() + }) + } + forumCopy['officialAdmins'] = officialAdmins + forumsCopy.push(forumCopy) + }); + req.forums = forumsCopy + next() + }).catch(next) + }, + function countAll(req, res, next) { + let forumsTopicPromises = req.forums.map(forum => { + return Topic.find({ forum: forum._id }).exec() + }); + Promise.all(forumsTopicPromises) + .then(forumsTopics => { + let totalTopics = 0 + let openTopics = 0 + let closedTopics = 0 + forumsTopics.forEach(forumTopics => { + forumTopics.forEach(topic => { + if (!topic.draft && topic.public) { + totalTopics += 1 + if (topic.open) openTopics += 1 + else if (topic.closed) closedTopics += 1 + } + }) + }) + req.countTopics = totalTopics + req.countOpenTopics = openTopics + req.countClosedTopics = closedTopics + next() + }).catch(next) + }, + function countAllComments(req, res, next) { + let officialsPerForum = {} + req.forums.forEach( forum => { + officialsPerForum[forum._id] = forum.officialAdmins.map(official => official.user.toString()) + }) + let forumsTopicPromises = req.forums.map(forum => { + return Topic.find({ forum: forum._id }).exec() + }); + Promise.all(forumsTopicPromises) + .then(forumsTopics => { + let officialsPerTopic = {} + let topicIds = [] + forumsTopics.forEach(forumTopics => { + forumTopics.forEach(topic => { + topicIds.push(topic._id) + officialsPerTopic[topic._id] = officialsPerForum[topic.forum] + }) + }) + let commentsTopicPromises = topicIds.map(topic => { + return Comment.find({ reference: topic + '' }).exec() + }); + Promise.all(commentsTopicPromises) + .then(commentsTopic => { + const mergeArrays = (accumulator, currentValue) => accumulator.concat(currentValue) + let allComments = commentsTopic.reduce(mergeArrays, []) + let totalWithOfficialReply = 0; + let uniqueParticipants = [] + allComments.forEach(comment => { + let foundAtLeastOneOfficial = false + // ------ + if (!uniqueParticipants.includes(comment.author.toString()) + && !officialsPerTopic[comment.reference].includes(comment.author.toString())) { uniqueParticipants.push(comment.author.toString()) } + comment.replies.forEach(reply => { + if (!uniqueParticipants.includes(reply.author.toString()) + && !officialsPerTopic[comment.reference].includes(reply.author.toString())) { uniqueParticipants.push(reply.author.toString()) } + }) + // ------ + if (officialsPerTopic[comment.reference].includes(comment.author.toString())) { + foundAtLeastOneOfficial = true + } + else { + comment.replies.forEach(reply => { + if (officialsPerTopic[comment.reference].includes(reply.author.toString())) { + foundAtLeastOneOfficial = true + } + }) + } + // + if (foundAtLeastOneOfficial) totalWithOfficialReply += 1 + }) + res.status(200).json({ + countForums: req.forums.length, + countTopics: req.countTopics, + countOpenTopics: req.countOpenTopics, + countClosedTopics: req.countClosedTopics, + totalWithOfficialReply: totalWithOfficialReply, + totalComments: allComments.length, + uniqueParticipants: uniqueParticipants.length + }) + }) + }).catch(next) + } + // Promise.all([ + // apiv1.forum.all({ + // user: req.user, + // topicId: req.query.topicId, + // limit: 100000, + // page: 1, + // sort: 'createdAt' + // }), + // api.comments.listCount(req.query) + // ]).then((results) => { + // res.status(200).json({ + // status: 200, + // results: { + // comments: results[0] + // } + // }) + // }).catch(next) +) \ No newline at end of file diff --git a/ext/lib/site/boot/boot.js b/ext/lib/site/boot/boot.js index 9ec44d031..d4262d428 100644 --- a/ext/lib/site/boot/boot.js +++ b/ext/lib/site/boot/boot.js @@ -1,5 +1,5 @@ -import 'lib/boot/moment' import 'lib/translations/translations' import './overrides' + require('ext/lib/boot/routes')() require('lib/site/boot/boot') diff --git a/ext/lib/site/boot/routes.js b/ext/lib/site/boot/routes.js index 66b19a9f0..968bc5ef8 100644 --- a/ext/lib/site/boot/routes.js +++ b/ext/lib/site/boot/routes.js @@ -4,6 +4,7 @@ module.exports = function (multiForum) { var forum = multiForum ? '/:forum' : '' urlBuilder.register('site.topic', forum + '/consulta/:id') + urlBuilder.register('site.stats', 'stats') urlBuilder.register('site.help', 'ayuda') urlBuilder.register('site.help.article', 'ayuda/:article') -} +} \ No newline at end of file diff --git a/ext/lib/site/footer/assets/logo-footer.svg b/ext/lib/site/footer/assets/logo-footer.svg new file mode 100644 index 000000000..a94e92745 --- /dev/null +++ b/ext/lib/site/footer/assets/logo-footer.svg @@ -0,0 +1 @@ +Logo_Presidencia \ No newline at end of file diff --git a/ext/lib/site/footer/component.js b/ext/lib/site/footer/component.js index a1589abc3..a2b8f3482 100644 --- a/ext/lib/site/footer/component.js +++ b/ext/lib/site/footer/component.js @@ -11,7 +11,7 @@ export default class Footer extends Component {
- +

diff --git a/ext/lib/site/help/component.js b/ext/lib/site/help/component.js index 6d2108696..bf7d5a314 100644 --- a/ext/lib/site/help/component.js +++ b/ext/lib/site/help/component.js @@ -5,6 +5,8 @@ import Footer from 'ext/lib/site/footer/component' import Sidebar from 'ext/lib/site/help/sidebar/component' import MarkdownGuide from 'lib/site/help/md-guide/component' import * as articles from './articles' +import Stats from './stats/component' + export default class HelpLayout extends PureComponent { articles = [ @@ -19,6 +21,12 @@ export default class HelpLayout extends PureComponent { Content: () => , slug: 'acerca', path: '/ayuda/acerca' + }, + { + title: 'Estadisticas', + Content: Stats, + slug: 'estadisticas', + path: '/ayuda/estadisticas' }, { title: t('help.tos.title'), @@ -65,9 +73,9 @@ export default class HelpLayout extends PureComponent { activeSlug={active.slug} articles={this.articles} /> - +

- +
diff --git a/ext/lib/site/help/stats/component.js b/ext/lib/site/help/stats/component.js new file mode 100644 index 000000000..4588a6ef2 --- /dev/null +++ b/ext/lib/site/help/stats/component.js @@ -0,0 +1,86 @@ +import React, { PureComponent } from 'react' +import { Link } from 'react-router' +import t from 't-component' +import Footer from 'ext/lib/site/footer/component' +import Sidebar from 'ext/lib/site/help/sidebar/component' +import MarkdownGuide from 'lib/site/help/md-guide/component' + +export default class Stats extends PureComponent { + constructor (props) { + super(props) + this.state = { + isFetching: true, + countForums: 0, + countTopics: 0, + countOpenTopics: 0, + countClosedTopics: 0, + totalWithOfficialReply: 0, + totalComments: 0, + uniqueParticipants: 0, + error: '', + } + } + + componentDidMount(){ + this.fetchStats() + } + + fetchStats () { + fetch('/ext/api/stats/forums', { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(res => res.ok && res.json()) + .then(stats => { + this.setState({ + countForums: stats.countForums, + countTopics: stats.countTopics, + countOpenTopics: stats.countOpenTopics, + countClosedTopics: stats.countClosedTopics, + totalComments: stats.totalComments, + totalWithOfficialReply: stats.totalWithOfficialReply, + uniqueParticipants: stats.uniqueParticipants, + isFetching: false + }) + }) + .catch(err => { + console.error(err) + this.setState({error: 'fetch topics error'}) + }) + } + render () { + return ( +
+
+
Consultas en total
+
{this.state.countForums}
+
+
+
+
+

Total de ejes

+

{this.state.countTopics}

+
+
+

Total

comentarios

+

{this.state.totalComments}

+
+
+
+
+

Comentarios

atentidos

+

{this.state.totalWithOfficialReply}

+
+
+

Cantidad de

participantes

+

{this.state.uniqueParticipants}

+
+
+
+ ) + } +} diff --git a/ext/lib/site/help/styles.styl b/ext/lib/site/help/styles.styl index 2615d27e1..858ef3eb8 100644 --- a/ext/lib/site/help/styles.styl +++ b/ext/lib/site/help/styles.styl @@ -11,3 +11,28 @@ max-width: 100% padding: 0 0 0 20px list-style-position: inside + +.stats + .forums-total-container + padding: 15px + background-color: #0695d6 + border-radius: 5px; + + .data-value, .data-title + display: block + color: #FFF + font-size: 1.4rem + .data-title + font-weight: 700 + @media screen and (min-width: 750px) + .data-value, .data-title + font-size: 2rem + .data-title + float: left + .data-value + float: right; + + .text-center + text-align: center !important + .subtitle + font-weight: 300 diff --git a/ext/lib/site/home-forum/component.js b/ext/lib/site/home-forum/component.js index 4dfbe3313..6ae839242 100644 --- a/ext/lib/site/home-forum/component.js +++ b/ext/lib/site/home-forum/component.js @@ -7,6 +7,7 @@ import topicStore from 'lib/stores/topic-store/topic-store' import Footer from 'ext/lib/site/footer/component' import TopicCard from 'ext/lib/site/cards-slider/topic-card/component' import ForumDescription from './forum-description/component' +import ForumStat from './forum-stats/component' export default class HomeForum extends Component { constructor (props) { @@ -95,7 +96,7 @@ export default class HomeForum extends Component {

{forum.title}

- { forum.extra.contentType === 'ejes' && + { (forum.extra.contentType === 'ejes' || forum.extra.contentType === undefined) && @@ -131,6 +132,7 @@ export default class HomeForum extends Component { {forum.summary}
} +
{this.state.topics.length > 0 && (forum.extra.contentType === 'ejes' || forum.extra.contentType === undefined) &&
{`${this.state.topics.length} ${this.state.topics.length > 1 ? 'ejes comprenden' : 'eje comprende'} esta consulta`}
diff --git a/ext/lib/site/home-forum/forum-stats/component.js b/ext/lib/site/home-forum/forum-stats/component.js new file mode 100644 index 000000000..75fe6f6c7 --- /dev/null +++ b/ext/lib/site/home-forum/forum-stats/component.js @@ -0,0 +1,89 @@ +import React, { Component } from 'react' + +export default class extends Component { + constructor(props) { + super(props) + + this.state = { + isFetching: true, + totalWithOfficialReply: 0, + totalComments: 0, + uniqueParticipants: 0, + error: null, + } + } + + componentDidMount() { + this.fetchStats() + } + + fetchStats() { + fetch(`/ext/api/stats/forum/${this.props.forum.name}`, { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(res => res.ok && res.json()) + .then(stats => { + this.setState({ + isFetching: false, + totalWithOfficialReply: stats.totalWithOfficialReply, + totalComments: stats.totalComments, + uniqueParticipants: stats.uniqueParticipants + }) + }) + .catch(err => { + console.error(err) + this.setState({ + isFetching: false, + error: 'Error al obtener las estadísticas' + }) + }) + } + + render() { + return ( +
+
+
+
+
+
Comentarios
hechos
+ { + this.state.isFetching ? +
+ :
{this.state.totalComments}
+ } +
+
+
Comentarios
respondidos
+ { + this.state.isFetching ? +
+ :
{Math.ceil((this.state.totalWithOfficialReply/(this.state.totalComments || 1))*100)}%
+ } +
+
+
Participantes
que comentaron
+ { + this.state.isFetching ? +
+ :
{this.state.uniqueParticipants}
+ } +
+
+ { + this.state.error && !this.state.isFetching && +
+ {this.state.error || 'Error al obtener las estadísticas'} +
+ } +
+
+
+ ) + } +} \ No newline at end of file diff --git a/ext/lib/site/home-forum/forum-stats/styles.styl b/ext/lib/site/home-forum/forum-stats/styles.styl new file mode 100644 index 000000000..29668c835 --- /dev/null +++ b/ext/lib/site/home-forum/forum-stats/styles.styl @@ -0,0 +1,33 @@ +.stat-container + .stat-title + font-weight: 500; + font-size: 1.2rem; + text-align: center; + line-height: normal; + .stat-value + font-size: 60px; + font-weight: 800; + color: #0695d6; + text-align: center; + .spinning-loader + width: 40px; + height: 40px; + margin: 13px auto 0 + border: 5px solid rgba(29, 161, 242, 0.2); + border-left-color: #0695d6; + border-radius: 50%; + background: transparent; + animation-name: rotate-s-loader; + animation-iteration-count: infinite; + animation-duration: 1s; + animation-timing-function: linear; + position: relative; + +.alert.alert-danger.text-center + text-align: center; + +@keyframes rotate-s-loader + from + transform: rotate(0) + to + transform: rotate(360deg) \ No newline at end of file diff --git a/ext/lib/site/home-forum/styles.styl b/ext/lib/site/home-forum/styles.styl index e65a6412a..1823370aa 100644 --- a/ext/lib/site/home-forum/styles.styl +++ b/ext/lib/site/home-forum/styles.styl @@ -67,4 +67,5 @@ width: 300px margin: 0 10px 20px -@import './forum-description/styles.styl' \ No newline at end of file +@import './forum-description/styles.styl' +@import './forum-stats/styles.styl' \ No newline at end of file diff --git a/ext/lib/site/home-multiforum/assets/header_consulta-publica.png b/ext/lib/site/home-multiforum/assets/header_consulta-publica.png new file mode 100644 index 000000000..ba45a1a7a Binary files /dev/null and b/ext/lib/site/home-multiforum/assets/header_consulta-publica.png differ diff --git a/ext/lib/site/home-multiforum/assets/icono_consulta-publica-1.svg b/ext/lib/site/home-multiforum/assets/icono_consulta-publica-1.svg new file mode 100644 index 000000000..315fe6338 --- /dev/null +++ b/ext/lib/site/home-multiforum/assets/icono_consulta-publica-1.svg @@ -0,0 +1 @@ +icono_consulta-publica-1 \ No newline at end of file diff --git a/ext/lib/site/home-multiforum/assets/icono_consulta-publica-2.svg b/ext/lib/site/home-multiforum/assets/icono_consulta-publica-2.svg new file mode 100644 index 000000000..b27a80b91 --- /dev/null +++ b/ext/lib/site/home-multiforum/assets/icono_consulta-publica-2.svg @@ -0,0 +1 @@ +icono_consulta-publica-2 \ No newline at end of file diff --git a/ext/lib/site/home-multiforum/assets/icono_consulta-publica-3.svg b/ext/lib/site/home-multiforum/assets/icono_consulta-publica-3.svg new file mode 100644 index 000000000..f9a0a5523 --- /dev/null +++ b/ext/lib/site/home-multiforum/assets/icono_consulta-publica-3.svg @@ -0,0 +1 @@ +icono_consulta-publica-3 \ No newline at end of file diff --git a/ext/lib/site/home-multiforum/assets/logo-header.svg b/ext/lib/site/home-multiforum/assets/logo-header.svg new file mode 100644 index 000000000..a94e92745 --- /dev/null +++ b/ext/lib/site/home-multiforum/assets/logo-header.svg @@ -0,0 +1 @@ +Logo_Presidencia \ No newline at end of file diff --git a/ext/lib/site/home-multiforum/assets/logo_consulta-publica.svg b/ext/lib/site/home-multiforum/assets/logo_consulta-publica.svg new file mode 100644 index 000000000..f60636b8f --- /dev/null +++ b/ext/lib/site/home-multiforum/assets/logo_consulta-publica.svg @@ -0,0 +1,153 @@ + + + + +logo_consulta-publica + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ext/lib/site/home-multiforum/component.js b/ext/lib/site/home-multiforum/component.js index 555f8a661..5f5811451 100644 --- a/ext/lib/site/home-multiforum/component.js +++ b/ext/lib/site/home-multiforum/component.js @@ -89,12 +89,12 @@ class HomeMultiForum extends Component {
Logo @@ -126,7 +126,7 @@ class HomeMultiForum extends Component {
Informate
@@ -136,7 +136,7 @@ class HomeMultiForum extends Component {
Participá
@@ -146,7 +146,7 @@ class HomeMultiForum extends Component {
Compartí
diff --git a/ext/lib/translations/lib/es.json b/ext/lib/translations/lib/es.json index 7ab2c9e12..79cbce98c 100644 --- a/ext/lib/translations/lib/es.json +++ b/ext/lib/translations/lib/es.json @@ -1,12 +1,37 @@ { - "settings.sidebar.forums": "Administración", - "newsfeed.call-to-action.manage-forums": "Administración", - + "settings.sidebar.forums": "Consultas", + "newsfeed.call-to-action.manage-forums": "Consultas", + "admin-sidebar.topics.title": "Ejes", + "admin-sidebar.edit-forum.title": "Editar consulta", + "admin-topics-form.title.create": "Nuevo eje (o propuesta)", + "admin-topics-form.title.edit": "Edición de eje (o propuesta)", + "admin-permissions.visibility.closed.description": "Todos pueden ver la consulta. Eliges quienes pueden participar.", + "admin-permissions.visibility.collaborative.description": "Todos pueden ver y crear la consulta además de participar.", + "admin-permissions.visibility.private.description": "Eliges quién puede ver la consulta y participar.", + "admin-permissions.visibility.public.description": "Todos pueden ver la consulta y participar.", + "header.forums": "Mis Consultas", + "admin-topics.list.search.placeholder": "Buscar un eje...", + "admin-topics.list.title": "Temas del sistema", + "admin-topics.update-from-csv": "Actualizar temas desde .csv", + "forum.form.summary.placeholder": "¿De qué se trata tu consulta?", + "forum.form.title": "Crear mi Consulta", + "forum.form.edit.title": "Editar mi Consulta", + "forum.form.title.label": "Título", + "forum.form.title.placeholder": "El título para tu consulta", + "forum.form.url.label": "URL de la consulta", + "forum.form.url.unavailable": "La URL no está disponible", + "forum.status.creating": "Creando consulta...", + "admin-topics-form.description.tags": "Etiquetas para el eje/propuesta, separarlos apretando ENTER o TAB", "admin-topics-form.description.author": "Nombre del autor u organismo responsable", "admin-topics-form.description.authorUrl": "URL con información del autor", "admin-topics-form.placeholder.author": "Ministerio de Modernizacion", "admin-topics-form.placeholder.authorUrl": "https://www.argentina.gob.ar/modernizacion", - + "admin-sidebar.comments.title": "Comentarios y estadisticas", + "admin-stats.comments.adminMarks.important": "Destacado", + "admin-stats.comments.adminMarks.derive": "Derivar", + "admin-stats.comments.adminMarks.doesNotApply": "No aplica", + "admin-stats.comments.adminMarks.alert": "Alerta", + "admin-stats.comments.adminMarks.question": "Pregunta", "comment-card.flagged-as-spam": "Este comentario fue reportado como abuso", "comment-card.remove-argument": "Borrar comentario", "comments.argument-limited": "El comentario no puede exceder los 4096 caracteres", @@ -35,4 +60,4 @@ "proposal-options.must-be-signed-in": "Debés estar registrado para participar", "forum.form.rich.summary.label": "Resumen largo", "forum.form.contentType.label": "Tipo de contenido" -} +} \ No newline at end of file