diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 11a386e2..6a659416 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,6 +35,26 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements.txt + - name: Install PostgreSQL + run: | + sudo apt-get update + sudo apt-get install -y postgresql postgresql-contrib + + - name: Start PostgreSQL + run: | + sudo service postgresql start + + - name: Configure PostgreSQL + run: | + sudo -u postgres psql -c "CREATE DATABASE forunb_db;" + sudo -u postgres psql -c "CREATE USER forunb WITH PASSWORD 'balao123';" + sudo -u postgres psql -c "ALTER USER forunb CREATEDB;" + sudo -u postgres psql -c "ALTER ROLE forunb SET client_encoding TO 'utf8';" + sudo -u postgres psql -c "ALTER ROLE forunb SET default_transaction_isolation TO 'read committed';" + sudo -u postgres psql -c "ALTER ROLE forunb SET timezone TO 'UTC';" + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE forunb_db TO forunb;" + + - name: Configure enviroment run: make config diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 0ded548f..b0d5ea83 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -8,26 +8,31 @@ Os workflows são arquivos de configuração que definem um conjunto de ações ## CI -Este fluxo de trabalho do GitHub Actions é acionado em eventos de push para o branch `main` e pull requests. Ele consiste em um único job chamado `u-tests` que é executado na versão mais recente do Ubuntu. +Este fluxo de trabalho do GitHub Actions é acionado em eventos de push para o branch `main` e em pull requests. Ele executa testes unitários em uma matriz de versões do Python. ### Job: u-tests -O job `u-tests` usa uma matriz de estratégia para especificar a versão do Python a ser usada. Neste caso, está definido como `3.10`. +O job `u-tests` é responsável por configurar o ambiente necessário e executar os testes unitários do projeto. ### Steps -1. **Checkout projeto**: Esta etapa faz o checkout do repositório usando a ação `actions/checkout`. +1. **Checkout do projeto**: Esta etapa faz o checkout do repositório usando a ação `actions/checkout`. + +2. **Configurar Python ${{ matrix.python-version }}**: Esta etapa configura a versão do Python definida na matriz, utilizando a ação `actions/setup-python`. -2. **Configurar Python**: Esta etapa configura a versão especificada do Python usando a ação `actions/setup-python`. +3. **Cache do pip**: Esta etapa armazena em cache os pacotes instalados pelo pip, para acelerar as execuções subsequentes. A chave do cache é gerada a partir do sistema operacional do runner e do hash do arquivo `requirements.txt`. -3. **Cache pip**: Esta etapa armazena em cache as dependências do pip usando a ação `actions/cache`. A chave de cache é gerada com base no sistema operacional e no hash do arquivo `requirements.txt`. +4. **Instalar dependências**: Nesta etapa, o pip é atualizado e as dependências do projeto são instaladas a partir do arquivo `requirements.txt`. -4. **Instalar dependências**: Esta etapa instala as dependências do projeto executando o comando `pip install`. +5. **Instalar PostgreSQL**: Esta etapa instala o PostgreSQL no ambiente de execução. -5. **Configurar ambiente**: Esta etapa configura o ambiente executando o comando `make config`. +6. **Iniciar PostgreSQL**: Esta etapa inicia o serviço do PostgreSQL. -6. **Testes**: Esta etapa executa os testes usando o comando `python ./forunb/manage.py test forunb`. +7. **Configurar PostgreSQL**: Nesta etapa, o banco de dados e o usuário necessários para os testes são criados e configurados no PostgreSQL. +8. **Configurar ambiente**: Esta etapa executa o comando `make config` para configurar o ambiente necessário para os testes. + +9. **Executar testes**: Finalmente, esta etapa executa os testes unitários do projeto usando o comando `python ./forunb/manage.py test forunb`. ## Docs Deploy @@ -55,45 +60,53 @@ O job `deploy` é responsável por realizar o deploy da documentação utilizand ## Pylint -Este fluxo de trabalho do GitHub Actions é acionado em eventos de push para o branch `main` e pull requests. Ele consiste em um único job chamado `lint` que é executado na versão mais recente do Ubuntu. +Este fluxo de trabalho do GitHub Actions é acionado em eventos de push para o branch `main` e em pull requests. Ele executa a verificação de linting do código Python utilizando o Pylint. ### Job: lint -O job `lint` usa uma matriz de estratégia para especificar a versão do Python a ser usada. Neste caso, está definido como `3.10`. +O job `lint` é responsável por verificar a qualidade do código Python utilizando o Pylint. ### Steps 1. **Checkout do repositório**: Esta etapa faz o checkout do repositório usando a ação `actions/checkout`. -2. **Configurar Python**: Esta etapa configura a versão especificada do Python usando a ação `actions/setup-python`. +2. **Configurar Python ${{ matrix.python-version }}**: Esta etapa configura a versão do Python definida na matriz, utilizando a ação `actions/setup-python`. -3. **Cache pip**: Esta etapa armazena em cache as dependências do pip usando a ação `actions/cache`. A chave de cache é gerada com base no sistema operacional e no hash do arquivo `requirements.txt`. +3. **Cache do pip**: Esta etapa armazena em cache os pacotes instalados pelo pip para acelerar as execuções subsequentes. A chave do cache é gerada a partir do sistema operacional do runner e do hash do arquivo `requirements.txt`. -4. **Instalar dependências**: Esta etapa instala as dependências do projeto executando o comando `pip install`. +4. **Instalar dependências**: Nesta etapa, o pip é atualizado e as dependências do projeto são instaladas a partir do arquivo `requirements.txt`. -5. **Executar Pylint**: Esta etapa executa o Pylint em todos os arquivos `.py` dentro do diretório `forunb/main/`, excluindo os arquivos nas pastas `migrations/`, `management/` e `templatetags/`. +5. **Executar Pylint**: Nesta etapa, o Pylint é executado em todos os arquivos Python dentro do diretório `forunb`, exceto nos diretórios `migrations` e `management`, além dos diretórios `templatetags`. O resultado da execução é salvo em um arquivo `pylint_output.txt`. + - **Analisar resultado do Pylint**: O score final do Pylint é extraído do relatório e exibido. Se o score for maior que 8, o job é considerado bem-sucedido. Caso contrário, o job falha, e uma mensagem informando o score é exibida. ## SonarCloud -Este fluxo de trabalho do GitHub Actions é acionado em eventos de push para os branches `main` e `Development`, bem como em pull requests. Ele consiste em um único job chamado `sonarcloud` que é executado na versão mais recente do Ubuntu. +Este fluxo de trabalho do GitHub Actions é acionado em eventos de push para os branches `main` e `Development`, bem como em pull requests. Ele executa uma análise de código utilizando o SonarCloud, além de configurar o ambiente de teste e executar os testes com cobertura de código. ### Job: sonarcloud -O job `sonarcloud` realiza a análise de código e cobertura de testes utilizando o SonarCloud. +O job `sonarcloud` é responsável por configurar o ambiente, executar os testes com cobertura e realizar a análise de código com o SonarCloud. ### Steps 1. **Checkout do projeto**: Esta etapa faz o checkout do repositório usando a ação `actions/checkout`. -2. **Configurar Python**: Esta etapa configura a versão especificada do Python usando a ação `actions/setup-python`. A versão do Python é definida pelo uso de uma matriz. +2. **Configurar Python ${{ matrix.python-version }}**: Esta etapa configura a versão do Python utilizando a ação `actions/setup-python`. + +3. **Cache do pip**: Esta etapa armazena em cache os pacotes instalados pelo pip para acelerar as execuções subsequentes. A chave do cache é gerada a partir do sistema operacional do runner e do hash do arquivo `requirements.txt`. + +4. **Instalar dependências**: Nesta etapa, o pip é atualizado e as dependências do projeto são instaladas a partir do arquivo `requirements.txt`. + +5. **Instalar PostgreSQL**: Esta etapa instala o PostgreSQL no ambiente de execução. + +6. **Iniciar PostgreSQL**: Esta etapa inicia o serviço do PostgreSQL. -3. **Cache pip**: Esta etapa armazena em cache as dependências do pip usando a ação `actions/cache`. A chave de cache é gerada com base no sistema operacional e no hash do arquivo `requirements.txt`. +7. **Configurar PostgreSQL**: Nesta etapa, o banco de dados e o usuário necessários para os testes são criados e configurados no PostgreSQL. -4. **Instalar dependências**: Esta etapa instala as dependências do projeto executando o comando `pip install`. +8. **Configurar ambiente**: Esta etapa executa o comando `make config` para configurar o ambiente necessário para os testes. -5. **Configurar ambiente**: Esta etapa configura o ambiente de desenvolvimento executando o comando `make config`. +9. **Executar testes com cobertura**: Nesta etapa, os testes do projeto são executados utilizando o `coverage`, e um relatório XML da cobertura de código é gerado. -6. **Executar testes com cobertura**: Esta etapa executa os testes do Django utilizando a ferramenta `coverage` para medir a cobertura de código. Em seguida, gera um relatório em formato XML. +10. **SonarCloud Scan**: Finalmente, o SonarCloud é utilizado para realizar a análise de código. As variáveis de ambiente `GITHUB_TOKEN` e `SONAR_TOKEN` são usadas para autenticação e configuração. O relatório de cobertura de código gerado na etapa anterior é passado para o SonarCloud através do argumento `-Dsonar.python.coverage.reportPaths=coverage.xml`. -7. **Scan do SonarCloud**: Esta etapa executa o scan do SonarCloud, utilizando o token de autenticação armazenado nos segredos do GitHub (`SONAR_TOKEN`) e configurando o caminho para o relatório de cobertura gerado pela etapa anterior. diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 58a7f346..ad660b81 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -31,6 +31,23 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements.txt + - name: Install PostgreSQL + run: | + sudo apt-get update + sudo apt-get install -y postgresql postgresql-contrib + - name: Start PostgreSQL + run: | + sudo service postgresql start + - name: Configure PostgreSQL + run: | + sudo -u postgres psql -c "CREATE DATABASE forunb_db;" + sudo -u postgres psql -c "CREATE USER forunb WITH PASSWORD 'balao123';" + sudo -u postgres psql -c "ALTER USER forunb CREATEDB;" + sudo -u postgres psql -c "ALTER ROLE forunb SET client_encoding TO 'utf8';" + sudo -u postgres psql -c "ALTER ROLE forunb SET default_transaction_isolation TO 'read committed';" + sudo -u postgres psql -c "ALTER ROLE forunb SET timezone TO 'UTC';" + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE forunb_db TO forunb;" + - name: Configure enviroment run: make config @@ -46,4 +63,4 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: args: > - -Dsonar.python.coverage.reportPaths=coverage.xml \ No newline at end of file + -Dsonar.python.coverage.reportPaths=coverage.xml diff --git a/.gitignore b/.gitignore index fbd63a05..28a3e865 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ __pycache__/ local_settings.py db.sqlite3 db.sqlite3-journal -media # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # in your Git repository. Update and uncomment the following line accordingly. diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..edde4f7e --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +release: python forunb/manage.py migrate +web: gunicorn --pythonpath=forunb forunb.wsgi diff --git a/forunb/forunb/README.md b/forunb/forunb/README.md new file mode 100644 index 00000000..527c7535 --- /dev/null +++ b/forunb/forunb/README.md @@ -0,0 +1,17 @@ +# ForUnB - Configuração do Projeto + +Esta pasta `forunb` contém os arquivos principais de configuração e gerenciamento do projeto **ForUnB**. Diferente dos apps tradicionais do Django, esta pasta não contém models, views ou templates, mas é essencial para o funcionamento geral do projeto. + +## Descrição dos Arquivos +- **__init__.py:** Arquivo vazio que indica ao Python que este diretório deve ser tratado como um módulo. +- **asgi.py:** Configuração para a interface ASGI (Asynchronous Server Gateway Interface), usada para servir o projeto em um ambiente assíncrono. +- **settings/:** Diretório que contém os arquivos de configuração do projeto. +- **base.py:** Contém as configurações básicas e comuns a todos os ambientes. +- **production.py:** Configurações específicas para o ambiente de produção. +- **urls.py:** Define os roteamentos principais do projeto, conectando URLs a views. +- **wsgi.py:** Configuração para a interface WSGI (Web Server Gateway Interface), usada para servir o projeto em ambientes síncronos. + +## Licença +Este projeto está licenciado sob os termos da licença MIT. + +Esse `README.md` oferece uma visão geral da função da pasta `forunb` no projeto Django 'ForUnB', explicando a finalidade dos principais arquivos de configuração. \ No newline at end of file diff --git a/forunb/forunb/asgi.py b/forunb/forunb/asgi.py index 3a30ece9..170dca72 100644 --- a/forunb/forunb/asgi.py +++ b/forunb/forunb/asgi.py @@ -8,11 +8,9 @@ """ import os -from forunb.env import env, BASE_DIR +from decouple import config from django.core.asgi import get_asgi_application -env.read_env(os.path.join(BASE_DIR, '.env')) - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', env('DJANGO_SETTINGS_MODULE')) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', config('DJANGO_SETTINGS_MODULE')) application = get_asgi_application() diff --git a/forunb/forunb/env.py b/forunb/forunb/env.py index 57a80288..dcd67351 100644 --- a/forunb/forunb/env.py +++ b/forunb/forunb/env.py @@ -1,9 +1,6 @@ """ Module to manage environment variables and BASE_DIR. """ # my imports from pathlib import Path -import environ - -env = environ.Env() # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent diff --git a/forunb/forunb/settings/base.py b/forunb/forunb/settings/base.py index 8163e984..823e55f0 100644 --- a/forunb/forunb/settings/base.py +++ b/forunb/forunb/settings/base.py @@ -11,18 +11,14 @@ """ import os -from pathlib import Path -from forunb.env import BASE_DIR, env - -env.read_env(os.path.join(BASE_DIR, '.env')) +from decouple import config +from forunb.env import BASE_DIR # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env('SECRET_KEY') - -ALLOWED_HOSTS = ['*'] +SECRET_KEY = config('SECRET_KEY') # Application definition @@ -36,10 +32,13 @@ 'main', 'users', 'search', + 'cloudinary', + 'cloudinary_storage', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -72,13 +71,6 @@ # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } -} - # Password validation # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators @@ -118,12 +110,19 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = 'static/' +# Diretório para coletar arquivos estáticos em produção +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') + +# Diretórios onde procurar arquivos estáticos adicionais STATICFILES_DIRS = [ os.path.join(BASE_DIR, 'static'), ] +# Configuração do WhiteNoise +# STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media') diff --git a/forunb/forunb/settings/local.py b/forunb/forunb/settings/local.py index 6bf91651..c30f208c 100644 --- a/forunb/forunb/settings/local.py +++ b/forunb/forunb/settings/local.py @@ -1,8 +1,14 @@ """ Settings for local development. """ -from forunb.env import env -from .base import * +from forunb.settings.base import * #pylint: disable=W0401, W0614 # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env.bool('DJANGO_DEBUG', default=True) +DEBUG = True -ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=['localhost', '0.0.0.0', '127.0.0.1']) +ALLOWED_HOSTS = ['localhost', '0.0.0.0', '127.0.0.1'] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} diff --git a/forunb/forunb/settings/production.py b/forunb/forunb/settings/production.py index beb191e4..faa62f39 100644 --- a/forunb/forunb/settings/production.py +++ b/forunb/forunb/settings/production.py @@ -1,8 +1,27 @@ """ Settings for production development. """ -from forunb.env import env +import os +import cloudinary +import cloudinary.uploader +import cloudinary.api from .base import * +import dj_database_url + # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env.bool('DJANGO_DEBUG', default=False) +DEBUG = False + +ALLOWED_HOSTS = ['.herokuapp.com', '.forunb.com'] + +DATABASES = { + 'default': dj_database_url.config( + default=config('DATABASE_URL') + ) +} + +CLOUDINARY_STORAGE = { + 'CLOUDINARY_URL': os.getenv('CLOUDINARY_URL'), +} + +DEFAULT_FILE_STORAGE = 'cloudinary_storage.storage.MediaCloudinaryStorage' -ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) # ainda estmos sem site +MEDIA_URL = 'https://res.cloudinary.com/dmezdx5mc/image/upload/' diff --git a/forunb/forunb/urls.py b/forunb/forunb/urls.py index 899dc2d0..723164f5 100644 --- a/forunb/forunb/urls.py +++ b/forunb/forunb/urls.py @@ -28,4 +28,5 @@ ] if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + \ No newline at end of file diff --git a/forunb/forunb/wsgi.py b/forunb/forunb/wsgi.py index c5a9eeb2..efe20bd1 100644 --- a/forunb/forunb/wsgi.py +++ b/forunb/forunb/wsgi.py @@ -8,11 +8,9 @@ """ import os -from forunb.env import env, BASE_DIR +from decouple import config from django.core.wsgi import get_wsgi_application -env.read_env(os.path.join(BASE_DIR, '.env')) - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', env('DJANGO_SETTINGS_MODULE')) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', config('DJANGO_SETTINGS_MODULE')) application = get_wsgi_application() diff --git a/forunb/main/README.md b/forunb/main/README.md new file mode 100644 index 00000000..c5f5a4f6 --- /dev/null +++ b/forunb/main/README.md @@ -0,0 +1,60 @@ +# Main App + +## Descrição +Este é o app `Main` do projeto **ForUnB**. Ele gerencia as principais funcionalidades do site, incluindo a página inicial, visualização de fóruns, interação com perguntas e respostas, notificações, e muito mais. + +## Funcionalidades +- **Página Inicial (index):** Exibe uma visão geral dos fóruns disponíveis e outras informações relevantes. +- **Visualização de Fórum (forum_detail):** Mostra detalhes de um fórum específico, incluindo suas perguntas e discussões. +- **Lista de Fóruns (forum_list):** Exibe uma lista de todos os fóruns disponíveis na plataforma. +- **Fóruns Seguidos (followed_forums):** Exibe os fóruns que o usuário segue. +- **Perguntas (questions):** Exibe uma lista de perguntas dentro de um fórum específico. +- **Postagens do Usuário (user_posts):** Mostra todas as postagens feitas por um usuário, incluindo perguntas e respostas. +- **Detalhes da Pergunta (question_detail):** Exibe detalhes de uma pergunta específica. +- **Seguir Fórum (follow_forum):** Permite que o usuário siga ou deixe de seguir um fórum. +- **Nova Pergunta (new_question):** Permite que o usuário crie uma nova pergunta em um fórum. +- **Nova Resposta (new_answer):** Permite que o usuário responda a uma pergunta existente. +- **Deletar Pergunta (delete_question):** Permite que o autor de uma pergunta a exclua. +- **Deletar Resposta (delete_answer):** Permite que o autor de uma resposta a exclua. +- **Notificações (notifications):** Exibe notificações para o usuário sobre atividades relevantes. +- **Upvote:** Permite que o usuário dê upvotes em perguntas e respostas. +- **Reportar Conteúdo (report):** Permite que o usuário denuncie conteúdo inapropriado. + +## Descrição dos Arquivos +- **admin.py:** Configurações do painel administrativo do Django para gerenciar fóruns, perguntas e respostas. +- **apps.py:** Configurações do app Main. +- **forms.py:** Define os formulários usados para criar e editar perguntas, respostas e outras interações. +- **models.py:** Define os modelos de dados para fóruns, perguntas, respostas, etc. +- **views.py:** Contém as views que tratam das requisições e renderizam os templates apropriados. +- **urls.py:** Contém as urls utilizadas no app Main. +- **templates/main/:** Contém os templates HTML para as páginas principais do site. +- **scraping.py:** Tem um read.me detalhado sobre este arquivo dentro da pasta main/management. + +## Como Usar +### Páginas Principais: +- **Página Inicial:** A página inicial do site, carregada através da view index, está associada ao template index.html. +- **Visualização de Fórum:** A view forum_detail renderiza a página de detalhes de um fórum utilizando o template forum_detail.html. +- **Lista de Fóruns:** A lista de todos os fóruns disponíveis é renderizada pela view forum_list utilizando o template forums.html. + +### Interação com Fóruns e Perguntas: +- **Seguir Fóruns:** A view follow_forum permite que o usuário siga ou deixe de seguir um fórum. +- **Nova Pergunta:** A view new_question permite que o usuário crie uma nova pergunta, utilizando o template new_question.html. +- **Nova Resposta:** A view new_answer permite que o usuário adicione uma resposta a uma pergunta, utilizando o template new_answer.html. + +### Gerenciamento de Conteúdo: +- **Deletar Pergunta e Resposta:** As views delete_question e delete_answer permitem que o autor exclua sua pergunta ou resposta. +- **Upvotes e Denúncias:** As funcionalidades de upvote e reportar conteúdo são gerenciadas por views específicas que tratam essas interações. + +### Notificações: +- **Notificações:** A view notifications exibe as notificações do usuário, renderizando o template notifications.html. + +## Testes: +Os testes automatizados para este app estão em tests. Execute-os com: +```bash +python manage.py test main +``` + +## Licença +Este projeto está licenciado sob os termos da licença MIT. + +Este `README.md` fornece uma visão detalhada das funcionalidades do app `Main`, explicando como cada parte se integra no projeto 'ForUnB'. \ No newline at end of file diff --git a/forunb/main/forms.py b/forunb/main/forms.py index ff7c1acf..39a14ef8 100644 --- a/forunb/main/forms.py +++ b/forunb/main/forms.py @@ -9,7 +9,7 @@ class ForumForm(forms.ModelForm): """ Form to create a forum. """ - class Meta: # pylint: disable=R0903 + class Meta: # pylint: disable=R0903 """ Meta class for ForumForm. """ model = Forum fields = ['title', 'description'] @@ -21,7 +21,7 @@ class Meta: # pylint: disable=R0903 class QuestionForm(forms.ModelForm): """ Form to create a question. """ - class Meta: # pylint: disable=R0903 + class Meta: # pylint: disable=R0903 """ Meta class for QuestionForm. """ model = Question fields = ['title', 'description', 'is_anonymous', 'image'] @@ -45,17 +45,22 @@ class Meta: # pylint: disable=R0903 class AnswerForm(forms.ModelForm): """ Form to create an answer. """ - class Meta: # pylint: disable=R0903 + class Meta: # pylint: disable=R0903 """ Meta class for AnswerForm. """ model = Answer fields = ['text', 'is_anonymous', 'image'] + widgets = { + 'text': forms.Textarea(attrs={ + 'id': 'id_answer_text', + 'class': 'texto descricao form-control mt-2', + 'placeholder': 'Escreva sua resposta...', + 'rows': 5, + 'cols': 50, + }), + } labels = { 'text': '' } - widgets = { - 'text': forms.Textarea(attrs={'cols': 80}), - 'id': 'id_answer_text', - } User = get_user_model() @@ -63,7 +68,7 @@ class Meta: # pylint: disable=R0903 class ReportForm(forms.ModelForm): """ Form to report a question or answer. """ - class Meta: # pylint: disable=R0903 + class Meta: # pylint: disable=R0903 """ Meta class for ReportForm. """ model = Report fields = ['reason', 'details'] diff --git a/forunb/main/management/commands/README.md b/forunb/main/management/commands/README.md new file mode 100644 index 00000000..d59d0bdc --- /dev/null +++ b/forunb/main/management/commands/README.md @@ -0,0 +1,34 @@ +# Scraping de Disciplinas do Sigaa da UnB + +Este projeto realiza a raspagem das disciplinas disponíveis no site Sigaa da UnB e as salva como fóruns no banco de dados do projeto Django 'ForUnB'. + +## Arquivos Principais + +- **main/management/commands/scraping_sigaa.py** +- **main/scraping.py** + +## Descrição dos Arquivos + +### scraping_sigaa.py + +Este é um comando personalizado do Django que executa a raspagem das disciplinas do Sigaa da UnB e cria fóruns correspondentes no banco de dados. + +#### Funcionalidades: +- Deleta todos os fóruns existentes para evitar duplicações. +- Raspa as disciplinas de departamentos específicos para um determinado ano e período. +- Cria novos fóruns no banco de dados para cada disciplina raspada. + +### scraping.py +Este arquivo contém a lógica de raspagem propriamente dita. Ele define a classe `DisciplineWebScraper`, que lida com as requisições HTTP ao site Sigaa e a extração dos dados das disciplinas. + +#### Funcionalidades: +- Faz uma requisição POST ao Sigaa com os dados do departamento, ano e período. +- Analisa a resposta do Sigaa e extrai as disciplinas em uma tabela. +- Organiza as disciplinas em um dicionário que é retornado para ser utilizado no comando Django. + +## Como Usar + +Execute o comando de raspagem: +```bash +python manage.py scraping_sigaa +``` \ No newline at end of file diff --git a/forunb/main/migrations/0011_alter_question_author.py b/forunb/main/migrations/0011_alter_question_author.py new file mode 100644 index 00000000..f6078efe --- /dev/null +++ b/forunb/main/migrations/0011_alter_question_author.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.14 on 2024-08-22 20:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('main', '0010_report_answer_alter_report_question'), + ] + + operations = [ + migrations.AlterField( + model_name='question', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/forunb/main/models.py b/forunb/main/models.py index 574296f0..e0a89849 100644 --- a/forunb/main/models.py +++ b/forunb/main/models.py @@ -4,6 +4,7 @@ from django.conf import settings from PIL import Image from users.models import CustomUser +from cloudinary.models import CloudinaryField class Forum(models.Model): @@ -52,12 +53,13 @@ class Question(Post): default=0, verbose_name='Favorited Count' ) is_anonymous = models.BooleanField(default=False, verbose_name='') - image = models.ImageField( - upload_to='media/question_images/', blank=True, null=True - ) + image = CloudinaryField('image', blank=True, null=True) upvoters = models.ManyToManyField( settings.AUTH_USER_MODEL, related_name='upvoted_questions', blank=True ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='questions' + ) def __str__(self): """String representation of a question.""" @@ -98,9 +100,7 @@ class Answer(Post): is_anonymous = models.BooleanField( default=False, verbose_name='Modo anônimo' ) - image = models.ImageField( - upload_to='media/answer_images/', blank=True, null=True - ) + image = CloudinaryField('image', blank=True, null=True) upvoters = models.ManyToManyField( settings.AUTH_USER_MODEL, related_name='upvoted_answers', blank=True ) diff --git a/forunb/main/templates/main/forum_detail.html b/forunb/main/templates/main/forum_detail.html index f56e7827..db3b3e1f 100644 --- a/forunb/main/templates/main/forum_detail.html +++ b/forunb/main/templates/main/forum_detail.html @@ -1,9 +1,11 @@ {% extends "base.html" %} {% block content %} + <div class="container-fluid mt-5 ms-0"> <div class="d-flex justify-content-between align-items-center forum-header"> - <h1 class="forum-title">{{ forum.title }} + <h1 class="forum-title flex-grow-1"> + {{ forum.title }} {% if user.is_authenticated %} <button id="follow-toggle" class="follow-toggle" data-forum-id="{{ forum.id }}"> {% if is_following %} @@ -14,24 +16,31 @@ <h1 class="forum-title">{{ forum.title }} </button> {% endif %} </h1> - <div class="dropdown"> - <button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" + </div> + <div class="d-flex forum-buttons"> + <div class="dropdown me-3"> + <button class="btn btn-question dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-expanded="false"> - Ordenar por + Filtrar </button> - <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton"> + <ul class="dropdown-menu mt-1" aria-labelledby="dropdownMenuButton"> <li><a class="dropdown-item" href="{% url 'main:forum_detail' forum.id %}?order_by=date">Mais - Recentes</a> + Recentes</a></li> + <li> + <hr class="dropdown-divider"> </li> <li><a class="dropdown-item" href="{% url 'main:forum_detail' forum.id %}?order_by=oldest">Menos - Recentes</a> + Recentes</a></li> + <li> + <hr class="dropdown-divider"> </li> <li><a class="dropdown-item" href="{% url 'main:forum_detail' forum.id %}?order_by=most_upvoted">Mais - Votadas</a> + Votadas</a></li> + <li> + <hr class="dropdown-divider"> </li> <li><a class="dropdown-item" href="{% url 'main:forum_detail' forum.id %}?order_by=least_upvoted">Menos - Votadas</a> - </li> + Votadas</a></li> </ul> </div> <a class="btn btn-question" href="{% url 'main:new_question' forum.id %}">Perguntar</a> @@ -44,6 +53,10 @@ <h1 class="forum-title">{{ forum.title }} {% for question in questions %} <div class="question-item"> <div class="question-meta"> + {% load static %} + <img class="profile-picture" + src="{% if question.is_anonymous or not question.author.photo %}{% static 'forunb/img/default.jpg' %}{% else %}{{ question.author.photo.url }}{% endif %}" + alt="{% if question.is_anonymous %}Imagem de perfil anônima{% else %}Foto de perfil do autor{% endif %}"> <span class="question-author"> {% if question.is_anonymous %} Anônimo @@ -57,6 +70,10 @@ <h1 class="forum-title">{{ forum.title }} <h5 class="question-title">{{ question.title }}</h5> <p class="question-description">{{ question.description }}</p> </a> + {% if question.image %} + <img src="{{ question.image.url }}" alt="Descrição da imagem relacionada à pergunta" + class="img-question mt-3"> + {% endif %} <div class="action-buttons"> <button class="btn btn-like" data-id="{{ question.id }}" data-type="question" onclick="toggleUpvote(this);"> diff --git a/forunb/main/templates/main/index.html b/forunb/main/templates/main/index.html index d14f7312..140d489c 100644 --- a/forunb/main/templates/main/index.html +++ b/forunb/main/templates/main/index.html @@ -5,41 +5,62 @@ <div class="container-fluid mt-5 ms-0"> <div class="forum-header"> <h2 class="home-head">Últimas Perguntas</h2> + <div class="dropdown"> + <button class="btn btn-question dropdown-toggle mb-2" type="button" id="dropdownMenuButton" + data-bs-toggle="dropdown" aria-expanded="false">Filtrar</button> + <ul class="dropdown-menu" aria-labelledby="dropdownMenuButton"> + <li><a class="dropdown-item" href="{% url 'main:index' %}?filter_by=latest">Últimas Perguntas Gerais</a> + </li> + <li><a class="dropdown-item" href="{% url 'main:index' %}?filter_by=followed">Perguntas dos Fóruns + Seguidos</a> + </li> + </ul> + </div> </div> {% load custom_filters %} <div class="home-container"> {% for question in latest_questions %} - <div class="question-item"> - <div class="home-meta"> - <span class="question-author"> - {% if question.is_anonymous %} - Anônimo - {% else %} - {{ question.author.username }} - {% endif %} - </span> • - <span class="question-date">há {{ question.created_at|custom_timesince }}</span> - <a href="{% url 'main:forum_detail' question.forum.id %}" class="forum-label"> - <span class="label-full">{{ question.forum.title }}</span> - <span class="label-short">{{ question.forum.title|first_word }}</span> - </a> - </div> - <a href="{% url 'main:question_detail' question.id %}"> - <h5 class="question-title">{{ question.title }}</h5> - <p class="question-description">{{ question.description }}</p> + <div class="question-item"> + <div class="home-meta"> + {% load static %} + <img class="profile-picture" + src="{% if question.is_anonymous or not question.author.photo %}{% static 'forunb/img/default.jpg' %}{% else %}{{ question.author.photo.url }}{% endif %}" + alt="{% if question.is_anonymous %}Imagem de perfil padrão{% else %}Foto de perfil do autor{% endif %}"> + <span class="question-author"> + {% if question.is_anonymous %} + Anônimo + {% else %} + {{ question.author.username }} + {% endif %} + </span> • + <span class="question-date">há {{ question.created_at|custom_timesince }}</span> + <a href="{% url 'main:forum_detail' question.forum.id %}" class="forum-label"> + <span class="label-full">{{ question.forum.title }}</span> + <span class="label-short">{{ question.forum.title|first_word }}</span> </a> - <div class="action-buttons"> - <button class="btn btn-like" data-id="{{ question.id }}" data-type="question" onclick="toggleUpvote(this);"> - <i class="bi {% if user in question.upvoters.all %}bi-heart-fill{% else %}bi-heart{% endif %}"></i> <span>{{ question.upvote_count }}</span> - </button> - <a href="{% url 'main:question_detail' question.id %}" class="btn btn-info"> - <i class="bi bi-chat-left-text"></i> {{ question.answers.count }} - </a> - </div> </div> - <hr class="question-separator"> + <a href="{% url 'main:question_detail' question.id %}"> + <h5 class="question-title">{{ question.title }}</h5> + <p class="question-description">{{ question.description }}</p> + </a> + {% if question.image %} + <img src="{{ question.image.url }}" alt="Descrição da imagem relacionada à pergunta" + class="img-question mt-3"> + {% endif %} + <div class="action-buttons"> + <button class="btn btn-like" data-id="{{ question.id }}" data-type="question" + onclick="toggleUpvote(this);"> + <i class="bi {% if user in question.upvoters.all %}bi-heart-fill{% else %}bi-heart{% endif %}"></i> + <span>{{ question.upvote_count }}</span> + </button> + <a href="{% url 'main:question_detail' question.id %}" class="btn btn-info"> + <i class="bi bi-chat-left-text"></i> {{ question.answers.count }} + </a> + </div> + </div> + <hr class="question-separator"> {% endfor %} </div> @@ -56,29 +77,29 @@ <h5 class="question-title">{{ question.title }}</h5> } function toggleUpvote(element) { - const questionId = element.getAttribute('data-id'); - const type = element.getAttribute('data-type'); - const url = type === 'question' ? `/toggle-upvote-question/${questionId}/` : `/toggle-upvote-answer/${questionId}/`; + const questionId = element.getAttribute('data-id'); + const type = element.getAttribute('data-type'); + const url = type === 'question' ? `/toggle-upvote-question/${questionId}/` : `/toggle-upvote-answer/${questionId}/`; - fetch(url, { - method: 'POST', - headers: { - 'X-CSRFToken': '{{ csrf_token }}', - 'Content-Type': 'application/json', - }, - }) - .then(response => response.json()) - .then(data => { - const icon = element.querySelector('i'); - if (icon.classList.contains('bi-heart')) { - icon.classList.remove('bi-heart'); - icon.classList.add('bi-heart-fill'); - } else { - icon.classList.remove('bi-heart-fill'); - icon.classList.add('bi-heart'); - } - element.querySelector('span').innerText = data.upvotes; - }); -} + fetch(url, { + method: 'POST', + headers: { + 'X-CSRFToken': '{{ csrf_token }}', + 'Content-Type': 'application/json', + }, + }) + .then(response => response.json()) + .then(data => { + const icon = element.querySelector('i'); + if (icon.classList.contains('bi-heart')) { + icon.classList.remove('bi-heart'); + icon.classList.add('bi-heart-fill'); + } else { + icon.classList.remove('bi-heart-fill'); + icon.classList.add('bi-heart'); + } + element.querySelector('span').innerText = data.upvotes; + }); + } </script> {% endblock %} \ No newline at end of file diff --git a/forunb/main/templates/main/new_question.html b/forunb/main/templates/main/new_question.html index 14e94bae..39ba50d1 100644 --- a/forunb/main/templates/main/new_question.html +++ b/forunb/main/templates/main/new_question.html @@ -6,127 +6,8 @@ <link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.css" rel="stylesheet"> <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.js"></script> -<style> - .nome { - color: #003366; - } - - .btn-cancelar { - background-color: #003366; - color: #ffffff; - font-weight: bold; - border: none; - padding: 10px 20px; - border-radius: 20px; - } - - .btn-cancelar:hover { - background-color: #a0b0c0; - } - - .btn-enviar { - background-color: #006633; - color: white; - border: none; - padding: 10px 20px; - border-radius: 20px; - font-weight: bold; - } - - .btn-enviar:hover { - background-color: #004c26; - } - - .response-form { - max-width: 50vw; - height: 70vh; - width: 100%; - } - - .input-title { - width: 50%; - font-size: 1.25rem; - /* Tamanho do texto maior */ - color: #003366; - /* Cor do texto digitado */ - } - - .cancel { - color: white; - } - - .texto { - background-color: #C7D2DD; - color: #003366; - } - - .texto::placeholder { - color: #003366; - /* Exemplo de cor do placeholder */ - opacity: 1; - /* Garantir que a cor aplicada seja visível */ - font-weight: bold; - /* Fonte em negrito */ - } - - .descricao.texto { - height: 200px; - /* Ajuste a altura conforme necessário */ - } - - .custom-label { - color: #003366; - font-weight: bold; - } - - #loading-spinner { - display: none; - text-align: center; - padding: 10px; - } - - .img-container { - width: 100%; - max-width: 400px; - /* Ajuste conforme necessário */ - max-height: 400px; - /* Ajuste conforme necessário */ - overflow: hidden; - margin: 0 auto; - /* Centraliza o contêiner da imagem */ - } - - #cropper-image { - width: 100%; - /* Ajusta a largura da imagem ao contêiner */ - height: auto; - /* Mantém a proporção da imagem */ - } - - .img-preview { - width: 200px; - height: 200px; - overflow: hidden; - margin: 10px; - border: 1px solid #ccc; - } - - @media (max-width: 767px) { - .response-form { - max-width: 100vw; - height: 100vh; - width: 100%; - } - } - - .MathJax { - font-family: 'Arial', sans-serif; - /* Substitua 'Arial' pela fonte desejada */ - } -</style> - -<div class="custom-container mt-5 ms-0"> - <h2 class="nome fw-bold">Perguntar em {{ forum.title }}</h2> +<h2 class="nome fw-bold ps-3 mt-5">Perguntar em {{ forum.title }}</h2> +<div class="scroll-container ps-3 ms-0 mb-4"> <div class="response-form"> <form method="post" action="{% url 'main:new_question' forum.id %}" enctype="multipart/form-data" onsubmit="return submitForm(event)"> @@ -143,17 +24,16 @@ <h2 class="nome fw-bold">Perguntar em {{ forum.title }}</h2> <label class="custom-label">{{ form.is_anonymous.label_tag }}</label> {{ form.is_anonymous }} </div> - <div class="form-group mt-3"> - <label for="image">Imagem:</label> - <input type="file" id="image" name="image" class="form-control-file" accept="image/*" - onchange="showCropper(event)"> - <div class="img-container mt-3"> - <img id="cropper-image" style="max-width: 100%;"> + <div class="form-group fw-bold mt-3"> + <label class ="imagem" for="image">Imagem :</label> + <input type="file" name="image" id="image" accept="image/*" onchange="showCropper(event)" style="display: none;"> + <button type="button" class="btn btn-imagem" onclick="document.getElementById('image').click();">Escolher Arquivo</button> + <div class="img-container mt-3"> + <img id="cropper-image"> + </div> + <div class="img-preview mt-3"></div> </div> - <div class="img-preview mt-3" style="width: 200px; height: 200px;"></div> - </div> - <div class="mt --1"> + <div class="mt-4"> <button type="button" class="btn btn-cancelar" onclick="cancelForm()">Cancelar</button> <button type="submit" class="btn btn-enviar">Enviar</button> </div> @@ -176,14 +56,13 @@ <h2 class="nome fw-bold">Perguntar em {{ forum.title }}</h2> text: 'Math', onAction: function () { editor.windowManager.open({ - title: 'Insert Math', + title: 'Inserir Fórmula', body: { type: 'panel', items: [ { type: 'textarea', name: 'math', - label: 'Math Formula', placeholder: '\\( E = mc^2 \\)' } ] @@ -191,11 +70,11 @@ <h2 class="nome fw-bold">Perguntar em {{ forum.title }}</h2> buttons: [ { type: 'cancel', - text: 'Cancel' + text: 'Cancelar' }, { type: 'submit', - text: 'Insert', + text: 'Enviar', primary: true } ], @@ -214,11 +93,21 @@ <h2 class="nome fw-bold">Perguntar em {{ forum.title }}</h2> }); }, plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table help wordcount mathjax', - toolbar: 'bold italic underline forecolor mathjax code' - , + toolbar: 'bold italic underline forecolor mathjax code', menubar: false, // Remove a barra de menu superior statusbar: false, // Remove a barra de status inferior - height: 400 + height: 150, + + content_style: ` + body { + font-weight: bold; + } + + body:focus { + background-color: #ffff; /* Cor de fundo ao digitar */ + color: #000000; + font-weight: normal; + `, }); function renderMath() { MathJax.Hub.Queue(["Typeset", MathJax.Hub]); @@ -236,34 +125,43 @@ <h2 class="nome fw-bold">Perguntar em {{ forum.title }}</h2> }); let cropper; - const image = document.getElementById('cropper-image'); +const image = document.getElementById('cropper-image'); + +function showCropper(event) { + const files = event.target.files; + const imgContainer = document.querySelector('.img-container'); // Seleciona a área de visualização da imagem + const imgPreview = document.querySelector('.img-preview'); // Seleciona a pré-visualização da imagem + + if (files && files.length > 0) { + const file = files[0]; + const validImageTypes = ['image/jpeg', 'image/png', 'image/gif']; + if (!validImageTypes.includes(file.type)) { + alert('Por favor, selecione uma imagem válida (JPEG, PNG, GIF).'); + event.target.value = ''; // Limpa o input + imgContainer.style.display = 'none'; // Esconde a área de visualização da imagem + return; + } - function showCropper(event) { - const files = event.target.files; - if (files && files.length > 0) { - const file = files[0]; - const validImageTypes = ['image/jpeg', 'image/png', 'image/gif']; - if (!validImageTypes.includes(file.type)) { - alert('Por favor, selecione uma imagem válida (JPEG, PNG, GIF).'); - event.target.value = ''; // Limpa o input - return; + const reader = new FileReader(); + reader.onload = function (e) { + image.src = e.target.result; + if (cropper) { + cropper.destroy(); } - - const reader = new FileReader(); - reader.onload = function (e) { - image.src = e.target.result; - if (cropper) { - cropper.destroy(); - } - cropper = new Cropper(image, { - aspectRatio: 1, - viewMode: 1, - preview: '.img-preview', - }); - }; - reader.readAsDataURL(file); - } + cropper = new Cropper(image, { + aspectRatio: 1, + viewMode: 1, + preview: '.img-preview', + }); + imgContainer.style.display = 'block'; // Mostra a área de visualização da imagem + imgPreview.style.display = 'block'; // Mostra a pré-visualização da imagem + }; + reader.readAsDataURL(file); + } else { + imgContainer.style.display = 'none'; // Esconde a área de visualização da imagem + imgPreview.style.display = 'none'; // Esconde a pré-visualização da imagem } +} function submitForm(event) { event.preventDefault(); @@ -284,7 +182,7 @@ <h2 class="nome fw-bold">Perguntar em {{ forum.title }}</h2> } } - function sendFormData(url, formData) { + function sendFormData(url, formData) { fetch(url, { method: 'POST', body: formData, @@ -309,14 +207,30 @@ <h2 class="nome fw-bold">Perguntar em {{ forum.title }}</h2> }); } + function cancelForm() { - window.history.back(); - } + // Limpa o input de arquivo + const fileInput = document.getElementById('image'); + fileInput.value = ''; // Reseta o valor do input de arquivo + // Oculta a área de visualização da imagem + document.querySelector('.img-container').style.display = 'none'; + document.querySelector('.img-preview').style.display = 'none'; - function cancelForm() { - window.history.back(); + // Remove a imagem de pré-visualização + document.getElementById('cropper-image').src = ''; + + // Destrói a instância do Cropper se ela existir + if (cropper) { + cropper.destroy(); + cropper = null; } + + // Esconde os botões de ação se não houver alterações no formulário + const actionButtons = document.getElementById('action-buttons'); + actionButtons.style.display = 'none'; +} + </script> -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/forunb/main/templates/main/notifications.html b/forunb/main/templates/main/notifications.html index 888c6267..442887ed 100644 --- a/forunb/main/templates/main/notifications.html +++ b/forunb/main/templates/main/notifications.html @@ -2,23 +2,93 @@ {% extends 'base.html' %} {% block content %} -<div class="container mt-4"> - <h2>Notificações</h2> - <ul class="list-group"> +<style> + .notificacao{ + color: #003366; + } + + .list-group-item-notificacao{ + background-color: #f5f5f5; + color: #003366; + } + + .list-group-item{ + background-color: #f5f5f5; + color: #003366; + border-radius: 10px; + display: flex; + flex-direction: column; + padding: 15px; + gap: 10px; + align-items: flex-start; + } + + .list-group-item:hover{ + background-color: #C7D2DD; + } + + .resposta{ + color: #003366; + font-size:x-large; + text-decoration: none; + } + + .resposta:hover{ + color: #003366; + } + + .notification-header { + display: flex; + justify-content: space-between; + align-items: center; + } + + .notification-content { + display: flex; + flex-direction: column; + } + + .notification-author { + font-weight: bold; + } + + .notification-time { + font-size: small; + color: #6c757d; + } + + @media (max-width: 575.98px) { + .list-group-item { + width: 85vw; + } + } +</style> + +<h2 class="notificacao p-4 fw-bold mt-3">Notificações</h2> +<div class="scroll-container p-4 rounded"> + <ul class="list-group list-group-flush"> {% for notification in notifications %} <li class="list-group-item"> - <strong>{% if notification.answer.is_anonymous %} - Anônimo - {% else %} - {{ notification.answer.author.username }} - {% endif %} - </strong> respondeu à sua pergunta " - <a href="{% url 'main:question_detail' notification.question.id %}"><strong>{{ notification.question.title }}</strong></a>" - <br> - <small class="text-muted">{{ notification.created_at }}</small> + <div class="notification-header"> + <span class="notification-author"> + {% if notification.answer.is_anonymous %} + Anônimo + {% else %} + {{ notification.answer.author.username }} + {% endif %} + </span> • + <small class="notification-time">{{ notification.created_at }}</small> + </div> + <div class="notification-content"> + <span>respondeu à sua pergunta:</span> + <a class="resposta mt-1 mb-1 fw-bold" href="{% url 'main:question_detail' notification.question.id %}"> + <strong>{{ notification.question.title }}</strong> + </a> + </div> </li> + <hr> {% empty %} - <li class="list-group-item">Você não tem notificações.</li> + <h4 class="text-center notificacao fw-bold mt-4">Você não tem notificações</h4> {% endfor %} </ul> </div> diff --git a/forunb/main/templates/main/question_detail.html b/forunb/main/templates/main/question_detail.html index 2a0fecef..92b428aa 100644 --- a/forunb/main/templates/main/question_detail.html +++ b/forunb/main/templates/main/question_detail.html @@ -6,97 +6,6 @@ <link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.css" rel="stylesheet"> <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.js"></script> -<style> - .answer-list { - min-height: 50vh; - /* Define a altura mínima como 50% da altura da tela */ - max-height: 70vh; - /* Define a altura máxima como 70% da altura da tela */ - overflow-y: auto; - /* Adiciona uma barra de rolagem vertical se o conteúdo ultrapassar a altura máxima */ - padding: 1rem; - /* Adiciona um espaçamento interno */ - background-color: #f9f9f9; - /* Adiciona uma cor de fundo para melhor visualização */ - border-radius: 8px; - /* Adiciona bordas arredondadas */ - } - - /* Estilos adicionais para o Cropper.js */ - .img-container { - max-width: 100%; - } - - .img-preview { - overflow: hidden; - margin: 10px; - border: 1px solid #ccc; - } - - .preformatted-text { - white-space: pre-wrap; - /* Preserva a formatação original e as quebras de linha */ - word-wrap: break-word; - /* Quebra palavras longas */ - } - - /* Estilos para o spinner */ - #loading-spinner { - display: none; - text-align: center; - padding: 10px; - } - - #reportModal { - display: none; - position: fixed; - z-index: 1000; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: 400px; /* Largura do modal */ - background-color: white; - border: 1px solid #ccc; - border-radius: 10px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); - padding: 20px; - } - - #reportModal .modal-content { - position: relative; - padding: 20px; - } - - #reportModal .close { - position: absolute; - top: 10px; - right: 10px; - color: #aaa; - font-size: 24px; - font-weight: bold; - cursor: pointer; - } - - #reportModal .close:hover, - #reportModal .close:focus { - color: black; - text-decoration: none; - cursor: pointer; - } - - .modal-header { - margin-bottom: 15px; - } - - .modal-body { - margin-bottom: 15px; - } - - .modal-footer { - text-align: right; - } -</style> - <div class="custom-container mt-5 ms-0"> <div class="questions-item"> <div class="question-info"> @@ -105,6 +14,9 @@ class="bi bi-arrow-return-left"></i></a> </div> <div class="question-meta"> + {% load static %} + <img class="profile-picture" + src="{% if question.is_anonymous or not question.author.photo %}{% static 'forunb/img/default.jpg' %}{% else %}{{ question.author.photo.url }}{% endif %}"> <span class="question-author"> {% if question.is_anonymous %} Anônimo @@ -117,7 +29,8 @@ <h5 class="question-title">{{ question.title }}</h5> <p class="question-description preformatted-text">{{ question.description }}</p> {% if question.image %} - <img src="{{ question.image.url }}" alt="Imagem da pergunta" class="img-fluid mt-3"> + <img src="{{ question.image.url }}" alt="Descrição da imagem relacionada à pergunta" + class="img-question mt-3"> {% endif %} <div class="action-buttons"> <button class="btn btn-like" data-id="{{ question.id }}" data-type="question" @@ -128,11 +41,14 @@ <h5 class="question-title">{{ question.title }}</h5> <a href="{% url 'main:question_detail' question.id %}" class="btn btn-info"> <i class="bi bi-chat-left-text"></i> {{ question.answers.count }} </a> + <button type="button" class="btn btn-alert" onclick="showReportForm('{{ question.id }}', 'question')"> + <i class="bi bi-exclamation-triangle-fill"></i> + </button> </div> - <button class="btn btn-warning mt-3" onclick="showReportForm('{{ question.id }}', 'question')">Denunciar</button> + <div id="reportModal" class="modal" style="display: none;"> - <div class="modal-content"> + <div class="modal-content fw-bold"> <span class="close" onclick="closeReportForm()">×</span> <h2>Denunciar <span id="reportType"></span></h2> <form id="reportForm" method="post" action=""> @@ -140,33 +56,36 @@ <h2>Denunciar <span id="reportType"></span></h2> <!-- Renderização Manual das Opções de Razões --> <div class="form-group"> <label for="id_reason">Motivo da denúncia</label> - <div class="form-check"> - <input type="radio" id="id_reason_ofensivo" name="reason" value="ofensivo"> + <div class="form-check mt-2"> + <input type="checkbox" id="id_reason_ofensivo" name="reason" value="ofensivo"> <label for="id_reason_ofensivo">Conteúdo ofensivo</label> </div> <div class="form-check"> - <input type="radio" id="id_reason_irrelevante" name="reason" value="irrelevante"> + <input type="checkbox" id="id_reason_irrelevante" name="reason" value="irrelevante"> <label for="id_reason_irrelevante">Irrelevante para o fórum</label> </div> <div class="form-check"> - <input type="radio" id="id_reason_outros" name="reason" value="outros"> + <input type="checkbox" id="id_reason_outros" name="reason" value="outros"> <label for="id_reason_outros">Outros</label> </div> </div> - + <!-- Campo de Detalhes --> <div class="form-group"> - <label for="id_details">Detalhes adicionais</label> - <textarea id="id_details" name="details" rows="3" placeholder="Detalhes adicionais (opcional)"></textarea> + <label for="id_details"></label> + <textarea class="form-control fw-bold" id="id_details" name="details" rows="3" + placeholder="Detalhes adicionais (opcional)"></textarea> + </div> + <div class="d-flex justify-content-center mt-3"> + <button type="button" class="btn-cancel btn-secondary me-2" + onclick="closeReportForm()">Cancelar</button> + <button type="submit" class="btn_denuncia">Denunciar</button> </div> - - <button type="button" class="btn btn-secondary" onclick="closeReportForm()">Cancelar</button> - <button type="submit" class="btn btn-danger">Enviar Denúncia</button> </form> </div> </div> - <div class="response-area"> + <div class="response-area mt-4"> <input type="text" class="form-control" placeholder="Adicionar uma resposta" onclick="showResponseForm()" readonly> <div class="response-form d-none"> @@ -175,18 +94,21 @@ <h2>Denunciar <span id="reportType"></span></h2> {% csrf_token %} <textarea id="id_answer_text" name="text" class="form-control mt-2" placeholder="Escreva sua resposta..."></textarea> - <div class="form-group mt-3"> - <label for="image">Imagem:</label> - <input type="file" id="image" name="image" class="form-control-file" accept="image/*" - onchange="showCropper(event)"> + <div class="form-group fw-bold mt-3"> + <label class="imagem" for="image">Imagem :</label> + <input type="file" name="image" id="image" accept="image/*" onchange="showCropper(event)" + style="display: none;"> + <button type="button" class="btn btn-imagem" + onclick="document.getElementById('image').click();">Escolher Arquivo</button> <div class="img-container mt-3"> - <img id="cropper-image" style="max-width: 100%;"> + <img id="cropper-image"> </div> - <div class="img-preview mt-3" style="width: 200px; height: 200px;"></div> + <div class="img-preview mt-3"></div> </div> <div class="form-check mt-2"> - <input type="checkbox" name="is_anonymous" class="form-check-input" id="isAnonymous"> - <label class="form-check-label" for="isAnonymous">Responder anonimamente</label> + <label class="form-check-label ms-2" for="isAnonymous"> Responder anonimamente</label> + <input type="checkbox" name="is_anonymous" class="form-check-input" id="isAnonymous" + style="display: inline-block; width: auto;"> </div> <div class="mt-1"> <button type="button" class="btn btn-cancel" onclick="cancelResponse()">Cancelar</button> @@ -200,6 +122,7 @@ <h2>Denunciar <span id="reportType"></span></h2> </div> </div> </div> + <hr class="answer-separator"> <div class="answer-list"> {% if answers %} @@ -207,6 +130,8 @@ <h2>Denunciar <span id="reportType"></span></h2> <div class="answer-item"> <div class="answer-info"> <div class="answer-meta"> + <img class="profile-picture" + src="{% if answer.is_anonymous or not answer.author.photo %}{% static 'forunb/img/default.jpg' %}{% else %}{{ answer.author.photo.url }}{% endif %}"> <span class="answer-author"> {% if answer.is_anonymous %} Anônimo @@ -219,7 +144,8 @@ <h2>Denunciar <span id="reportType"></span></h2> </div> <p class="answer-text preformatted-text">{{ answer.text }}</p> {% if answer.image %} - <img src="{{ answer.image.url }}" alt="Imagem da resposta" class="img-fluid mt-3"> + <img src="{{ answer.image.url }}" alt="Descrição da imagem relacionada à resposta" + class="img-question mt-3"> {% endif %} <div class="action-buttons-answer"> <button class="btn btn-like" data-id="{{ answer.id }}" data-type="answer" @@ -228,11 +154,15 @@ <h2>Denunciar <span id="reportType"></span></h2> class="bi {% if user in answer.upvoters.all %}bi-heart-fill{% else %}bi-heart{% endif %}"></i> <span>{{ answer.upvote_count }}</span> </button> - <a href="{% url 'main:question_detail' question.id %}" class="btn btn-info"> + <!-- <a href="{% url 'main:question_detail' question.id %}" class="btn btn-info"> <i class="bi bi-chat-left-text"></i> {{ answer.answers.count }} - </a> - <button class="btn btn-warning mt-3" onclick="showReportForm('{{ answer.id }}', 'answer')">Denunciar</button> + </a> --> + <button type="button" class="btn btn-alert" + onclick="showReportForm('{{ question.id }}', 'question')"> + <i class="bi bi-exclamation-triangle-fill"></i> + </button> </div> + </div> <hr class="answer-separator"> {% empty %} @@ -244,6 +174,90 @@ <h2>Denunciar <span id="reportType"></span></h2> </div> </div> + <style> + /* Estilos adicionais para o Cropper.js */ + + .img-container { + width: 80%; + max-width: 400px; + /* Ajuste conforme necessário */ + max-height: 400px; + /* Ajuste conforme necessário */ + overflow: hidden; + margin: 0 auto; + border-radius: 10px; + /* Centraliza o contêiner da imagem */ + } + + + .img-preview { + width: 200px; + height: 200px; + overflow: hidden; + margin: 10px; + border: 10px solid #ccc; + } + + .img-container, + .img-preview { + display: none; + /* Esconde a área de visualização por padrão */ + } + + #id_answer_text_ifr { + background-color: #C7D2DD; + border-radius: 5px; + } + + .tox .tox-toolbar__primary .tox-tbtn, + .tox .tox-toolbar__primary .tox-tbtn__select-label, + .tox .tox-toolbar__primary .tox-tbtn__icon-wrap svg { + color: #000000; + /* Cor dos ícones e textos */ + } + + .tox .tox-toolbar__primary .tox-tbtn:hover { + background-color: #C7D2DD; + /* Cor de fundo ao passar o mouse */ + } + + .tox .tox-toolbar__primary .tox-tbtn--enabled { + background-color: #C7D2DD; + /* Cor de fundo quando o botão está ativado */ + color: #fff; + /* Cor do texto quando o botão está ativado */ + + .imagem { + color: #003366; + } + + .btn-imagem { + color: #003366; + background-color: #C7D2DD; + font-weight: bold; + padding: 10px 20px; + border-radius: 20px; + } + + .btn-imagem:hover, + .form-control:hover { + background-color: #a0b0c0; + color: #003366; + } + + .response-area .form-check-label { + display: inline-block; + width: auto; + } + + .response-area .form-check-input { + display: inline-block; + width: auto; + margin-right: 5px; + } + } + </style> + <script src="https://cdn.tiny.cloud/1/xnvlz4kucskbnxpfwvjhg9dxecvbsq6lljrsssggdvf0wkc3/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script> <script> @@ -295,9 +309,19 @@ <h2>Denunciar <span id="reportType"></span></h2> }, plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table help wordcount mathjax', toolbar: 'bold italic underline forecolor mathjax code', - menubar: false, - statusbar: false, - height: 400 + menubar: false, // Remove a barra de menu superior + statusbar: false, // Remove a barra de status inferior + height: 150, + + content_style: ` + body { + font-weight: bold; + } + body:focus { + background-color: #ffff; /* Cor de fundo ao digitar */ + color: #000000; + font-weight: normal; + `, }); function renderMath() { @@ -319,6 +343,8 @@ <h2>Denunciar <span id="reportType"></span></h2> function showCropper(event) { const files = event.target.files; + const imgContainer = document.querySelector('.img-container'); // Seleciona a área de visualização da imagem + const imgPreview = document.querySelector('.img-preview'); // Seleciona a pré-visualização da imagem if (files && files.length > 0) { const file = files[0]; const validImageTypes = ['image/jpeg', 'image/png', 'image/gif']; @@ -339,8 +365,13 @@ <h2>Denunciar <span id="reportType"></span></h2> viewMode: 1, preview: '.img-preview', }); + imgContainer.style.display = 'block'; // Mostra a área de visualização da imagem + imgPreview.style.display = 'block'; }; reader.readAsDataURL(file); + } else { + imgContainer.style.display = 'none'; // Esconde a área de visualização da imagem + imgPreview.style.display = 'none'; // Esconde a pré-visualização da imagem } } @@ -404,6 +435,7 @@ <h2>Denunciar <span id="reportType"></span></h2> } function cancelResponse() { + // Parte 1: Cancelar a resposta var responseInput = document.querySelector('.response-area input'); var responseForm = document.querySelector('.response-form'); @@ -413,6 +445,33 @@ <h2>Denunciar <span id="reportType"></span></h2> } else { console.error('Elementos não encontrados.'); } + // Parte 2: Resetar a imagem e ocultar as áreas de visualização + const fileInput = document.getElementById('image'); + if (fileInput) { + fileInput.value = ''; // Reseta o valor do input de arquivo + } + + const imgContainer = document.querySelector('.img-container'); + const imgPreview = document.querySelector('.img-preview'); + const cropperImage = document.getElementById('cropper-image'); + + if (imgContainer && imgPreview && cropperImage) { + imgContainer.style.display = 'none'; // Oculta a área de visualização da imagem + imgPreview.style.display = 'none'; // Oculta a pré-visualização + cropperImage.src = ''; // Remove a imagem de pré-visualização + } + + // Destrói a instância de Cropper se ela existir + if (cropper) { + cropper.destroy(); + cropper = null; + } + + // Esconde os botões de ação se não houver alterações no formulário + const actionButtons = document.getElementById('action-buttons'); + if (actionButtons) { + actionButtons.style.display = 'none'; + } } function toggleLike(element) { @@ -452,44 +511,44 @@ <h2>Denunciar <span id="reportType"></span></h2> }); } -function showReportForm(itemId, itemType) { - const form = document.getElementById('reportForm'); - form.action = "{% url 'main:report' 0 1 %}".replace('0', itemId).replace('1', itemType); - document.getElementById("reportType").textContent = itemType === 'question' ? 'Pergunta' : 'Resposta'; - document.getElementById("reportModal").style.display = "block"; - } - - function closeReportForm() { - document.getElementById("reportModal").style.display = "none"; - } - - document.getElementById('reportForm').addEventListener('submit', function(event) { - event.preventDefault(); - const form = event.target; - fetch(form.action, { - method: 'POST', - body: new FormData(form), - headers: { - 'X-Requested-With': 'XMLHttpRequest', - }, - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - alert('Denúncia enviada com sucesso.'); - closeReportForm(); - location.reload(); - } else { - alert('Erro ao enviar a denúncia.'); - console.error('Erro:', data.errors); - } - }) - .catch(error => { - console.error('Erro:', error); - alert('Erro ao enviar a denúncia.'); + function showReportForm(itemId, itemType) { + const form = document.getElementById('reportForm'); + form.action = "{% url 'main:report' 0 1 %}".replace('0', itemId).replace('1', itemType); + document.getElementById("reportType").textContent = itemType === 'question' ? 'Pergunta' : 'Resposta'; + document.getElementById("reportModal").style.display = "block"; + } + + function closeReportForm() { + document.getElementById("reportModal").style.display = "none"; + } + + document.getElementById('reportForm').addEventListener('submit', function (event) { + event.preventDefault(); + const form = event.target; + fetch(form.action, { + method: 'POST', + body: new FormData(form), + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Denúncia enviada com sucesso.'); + closeReportForm(); + location.reload(); + } else { + alert('Erro ao enviar a denúncia.'); + console.error('Erro:', data.errors); + } + }) + .catch(error => { + console.error('Erro:', error); + alert('Erro ao enviar a denúncia.'); + }); }); - }); -</script> + </script> {% endblock %} \ No newline at end of file diff --git a/forunb/main/templates/main/questions.html b/forunb/main/templates/main/questions.html index 10013529..01d49b4f 100644 --- a/forunb/main/templates/main/questions.html +++ b/forunb/main/templates/main/questions.html @@ -3,137 +3,6 @@ {% block title %}Meus Posts{% endblock %} {% block content %} -<style> - -.full-height { - height: 100vh; /* Altura total da viewport */ - display: flex; - } - .questions-list, .answers-list { - font-weight: bolder; - border-radius: 8px; - } - - - .list-group-item { - margin-bottom: 10px; - border-radius: 20px; - display: flex; - justify-content: space-between; - align-items: center; - font-weight: bold; - } - - .scroll-container { - flex-grow: 1; - height: 400px; /* Defina a altura desejada */ - overflow-y: scroll; /* Continua permitindo o scroll */ - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* Internet Explorer and Edge */ - } - - .scroll-container::-webkit-scrollbar { - display: none; /* Chrome, Safari, and Opera */ - } - - .list-header { - position: sticky; - top: 0; - z-index: 1000; /* Mantém o cabeçalho sobre os conteúdos em scroll */ - color: #003366; - font-weight: bold; - font-size: 2rem; - } - - @media (max-width: 768px) { - .scroll-container { - flex-grow: 1; - height: 170px; /* Defina a altura desejada */ - overflow-y: scroll; /* Continua permitindo o scroll */ - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* Internet Explorer and Edge */ - } - - .questions-list, .answers-list { - width: 100%; - } - - .list-header { - position: sticky; - top: 0; - z-index: 1000; /* Mantém o cabeçalho sobre os conteúdos em scroll */ - color: #003366; - font-weight: bold; - font-size: 1.5rem; - } - } - - .custom-list-group-item { - background-color: #003366; /* Cor de fundo personalizada */ - } - - .custom-list-group-item a { - color: white; /* Cor do texto dos links */ - } - - .btn-excluir{ - background-color:#c61919; - color: white; - border: none; - padding: 5px 10px; - border-radius: 20px; - font-weight: bold; - } - - .btn-excluir:hover{ - background-color: #ff0101; - } - - .btn-cancel { - background-color: #C7D2DD; - color: #003366; - font-weight: bold; - border: none; - padding: 5px 10px; - border-radius: 20px; - } - - .btn-cancel:hover { - background-color: #a0b0c0; - } - - .confirm-popup { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: white; - padding: 20px; - border: 1px solid #ccc; - border-radius: 20px; - z-index: 1000; - box-shadow: 0 0 10px rgba(0,0,0,0.1); - } - - .confirm-popup-content { - text-align: center; - color: #003366; - } - - .hidden { - display: none; - } - - .confirm-popup button { - margin: 5px; - } - - .alerta{ - color: white; - background-color: #003366; - border-radius: 20px; - } -</style> <div class="container-fluid mb-0 full-height"> <div class="row w-100 no-gutters"> diff --git a/forunb/main/templatetags/_init_.py b/forunb/main/templatetags/__init__.py similarity index 100% rename from forunb/main/templatetags/_init_.py rename to forunb/main/templatetags/__init__.py diff --git a/forunb/main/tests/test_views.py b/forunb/main/tests/test_views.py index 100366b3..339b0862 100644 --- a/forunb/main/tests/test_views.py +++ b/forunb/main/tests/test_views.py @@ -6,17 +6,65 @@ User = get_user_model() - -class IndexViewTest(TestCase): - """ - Test suite for the index view in the main app. - """ - +class IndexViewTestCase(TestCase): + """ Test suite for the index view. """ def setUp(self): """ - Set up the test client for use in the tests. + Set up the test environment with a user, forums, and questions. """ self.client = Client() + self.user = User.objects.create_user( # pylint: disable=E1101 + email='test@aluno.unb.br', password='senha1010' + ) + self.forum1 = Forum.objects.create( # pylint: disable=E1101 + title="Python Programming", description="Discuss all things Python." + ) + self.forum2 = Forum.objects.create( # pylint: disable=E1101 + title="Django Tips", description="Tips and tricks for Django." + ) + self.question1 = Question.objects.create( # pylint: disable=E1101 + title="Python Question 1", + description="Description for Python Question 1", + forum=self.forum1, + author=self.user # Associando o usuário como autor + ) + self.question2 = Question.objects.create( # pylint: disable=E1101 + title="Django Question 1", + description="Description for Django Question 1", + forum=self.forum2, + author=self.user # Associando o usuário como autor + ) + + def test_index_view_latest_questions(self): + """ + Test that the index view returns the correct latest questions. + """ + response = self.client.get(reverse('main:index') + '?filter_by=latest') + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'main/index.html') + self.assertContains(response, self.question1.title) + self.assertContains(response, self.question2.title) + + def test_index_view_followed_forums(self): + """ + Test that authenticated users see questions from followed forums. + """ + self.user.followed_forums.add(self.forum1) + self.client.login(email='test@aluno.unb.br', password='senha1010') + + response = self.client.get(reverse('main:index') + '?filter_by=followed') + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'main/index.html') + self.assertContains(response, self.question1.title) + self.assertNotContains(response, self.question2.title) + + def test_index_view_followed_forums_redirect(self): + """ + Test that unauthenticated users are redirected when trying to access followed forums. + """ + response = self.client.get(reverse('main:index') + '?filter_by=followed') + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, f"{reverse('users:login')}?next={reverse('main:index')}") def test_index_view_status_code(self): """ diff --git a/forunb/main/views.py b/forunb/main/views.py index 376d3d41..0162ff50 100644 --- a/forunb/main/views.py +++ b/forunb/main/views.py @@ -5,15 +5,28 @@ from django.contrib import messages from django.http import JsonResponse from django.db.models import Count +from django.urls import reverse from bs4 import BeautifulSoup from main.models import Forum, Answer, Question, Notification # pylint: disable=E1101 from main.forms import QuestionForm, AnswerForm, ReportForm def index(request): - """Render the index page with the latest questions that have no associated reports.""" - latest_questions = Question.objects.filter( # pylint: disable=E1101 - reports__isnull=True).order_by('-created_at') + """Render the index page with the latest questions.""" + filter_by = request.GET.get('filter_by', 'latest') + + if filter_by == 'followed': + if request.user.is_authenticated: + latest_questions = Question.objects.filter( # pylint: disable=E1101 + forum__in=request.user.followed_forums.all(), reports__isnull=True + ).order_by('-created_at') + else: + return redirect(f"{reverse('users:login')}?next={request.path}") + else: + latest_questions = Question.objects.filter( # pylint: disable=E1101 + reports__isnull=True + ).order_by('-created_at') + return render(request, 'main/index.html', {'latest_questions': latest_questions}) @@ -21,7 +34,7 @@ def forum_detail(request, forum_id): """Render the forum detail page with the questions associated with the forum.""" forum = get_object_or_404(Forum, id=forum_id) order_by = request.GET.get('order_by', 'date') - questions = Question.objects.filter( # pylint: disable=E1101, W0621 + questions = Question.objects.filter( # pylint: disable=E1101, W0621 forum=forum).annotate(total_upvotes=Count('upvoters')) if order_by == 'least_upvoted': questions = questions.order_by('total_upvotes') @@ -66,7 +79,7 @@ def questions(request): def user_posts(request): """Render the user posts page with the questions and answers created by the user.""" user = request.user - user_questions = Question.objects.filter(author=user) # pylint: disable=E1101 + user_questions = Question.objects.filter(author=user) # pylint: disable=E1101 user_answers = Answer.objects.filter(author=user) # pylint: disable=E1101 context = { 'questions': user_questions, @@ -137,7 +150,7 @@ def new_answer(request, question_id): # Create notification for the question's author if question.author != request.user: - Notification.objects.create( # pylint: disable=E1101 + Notification.objects.create( # pylint: disable=E1101 user=question.author, question=question, answer=answer @@ -174,7 +187,7 @@ def delete_answer(request, pk): @login_required(login_url='/users/login') def notifications(request): """Render the notifications page with the user's notifications.""" - user_notifications = Notification.objects.filter( # pylint: disable=E1101 + user_notifications = Notification.objects.filter( # pylint: disable=E1101 user=request.user).order_by('-created_at') return render(request, 'main/notifications.html', {'notifications': user_notifications}) diff --git a/forunb/manage.py b/forunb/manage.py index 848453e2..07581e14 100644 --- a/forunb/manage.py +++ b/forunb/manage.py @@ -2,14 +2,12 @@ """Django's command-line utility for administrative tasks.""" import os import sys -from forunb.env import env, BASE_DIR +from decouple import config def main(): """Run administrative tasks.""" - # Read the environment variables from the .env file - env.read_env(os.path.join(BASE_DIR, '.env')) - os.environ.setdefault('DJANGO_SETTINGS_MODULE', env('DJANGO_SETTINGS_MODULE')) + os.environ.setdefault('DJANGO_SETTINGS_MODULE', config('DJANGO_SETTINGS_MODULE')) try: from django.core.management import execute_from_command_line # pylint: disable=C0415 except ImportError as exc: diff --git a/forunb/media/media/answer_images/cropped_image_cJpHPz9.png b/forunb/media/media/answer_images/cropped_image_cJpHPz9.png new file mode 100644 index 00000000..ac1b4110 Binary files /dev/null and b/forunb/media/media/answer_images/cropped_image_cJpHPz9.png differ diff --git a/forunb/search/README.md b/forunb/search/README.md new file mode 100644 index 00000000..1bc2c2ad --- /dev/null +++ b/forunb/search/README.md @@ -0,0 +1,19 @@ +# Search App + +## Descrição +Este é o app `Search` do projeto **ForUnB**. Ele fornece a funcionalidade de busca de fóruns, permitindo que os usuários encontrem rapidamente fóruns desejados. + +## Funcionalidade +- **Busca de Fóruns:** Permite que os usuários busquem por fóruns utilizando palavras-chave (nome ou código da matéria). A busca é realizada nos títulos dos fóruns. + +## Descrição dos Arquivos +- **apps.py:** Configurações do app Search. +- **views.py:** Contém as views que tratam as requisições de busca e retornam os resultados. +- **users.py:** Contém as urls utilizadas no app Search. +- **templates/search/search_results.html:** Template que exibe os resultados da busca. + +## Licença +Este projeto está licenciado sob os termos da licença MIT. + +Esse arquivo `README.md` fornece uma visão geral do app `Search` e como ele se encaixa no projeto 'ForUnB'. + diff --git a/forunb/search/urls.py b/forunb/search/urls.py index ec2336db..49ba1693 100644 --- a/forunb/search/urls.py +++ b/forunb/search/urls.py @@ -1,7 +1,7 @@ """URL configurations for the search application.""" from django.urls import path -from . import views +from search import views app_name = "search" # pylint: disable=C0103 diff --git a/forunb/static/css/styles.css b/forunb/static/css/styles.css index 0eeb9ff8..069a6b8b 100644 --- a/forunb/static/css/styles.css +++ b/forunb/static/css/styles.css @@ -1,4 +1,27 @@ /* BASE.HTML */ +body { + background-color: #f5f5f5; +} + +header { + flex-shrink: 0; /* Garante que o header não encolha */ +} + +nav { + flex: 1; /* Ocupa o espaço restante disponível */ + overflow: hidden; /* Evita rolagem dentro do nav */ +} + +.footnote { + font-size: medium; + color: white; + text-align: center; + padding: 10px; +} + +.container-fluid { + background-color: #f5f5f5 !important; +} /* Ajustes gerais */ .bg-green { @@ -12,6 +35,7 @@ min-height: 100vh; display: flex; flex-direction: column; + background-color: inherit; } /* Barra lateral */ @@ -89,6 +113,11 @@ border-radius: 20px; margin-right: 15px; font-size: 1.1rem; + display: flex; + align-items: center; + gap: 10px; + position: relative; + overflow: hidden; } .btn-login:hover { @@ -96,6 +125,10 @@ color: #003366; } +.btn-login .username { + padding-left: 30px; +} + .dropdown-menu { background-color: #C7D2DD; border-radius: 20px; @@ -113,6 +146,17 @@ color: #003366; } +.btn-login img.profile-photo { + width: 35px; + height: 35px; + border-radius: 50%; + object-fit: cover; + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); +} + .btn-home { background-image: linear-gradient(45deg, #003366, #0056a0) !important; color: #C7D2DD; @@ -128,6 +172,7 @@ .btn-home:hover { background-image: linear-gradient(45deg, #003366, #0056a0) !important; transform: scale(1.05) !important; + color: white !important; } .btn-home i { @@ -150,7 +195,7 @@ /* Ajustes para telas menores que 1200px */ @media (max-width: 1199px) { .search-bar { - margin-left: 0; /* Remove a margem à esquerda */ + margin-left: 0; } #accordionSidebar { @@ -161,11 +206,12 @@ top: 0; left: 0; z-index: 1000; - background-color: #006633; /* Garante que a cor de fundo seja a mesma */ - overflow-y: auto; /* Adiciona rolagem se necessário */ + background-color: #006633; + overflow-y: auto; } + #accordionSidebar.show { - display: block; + display: flex; } #sidebarToggle { @@ -179,21 +225,41 @@ right: 10px; z-index: 1100; /* Garante que o botão de fechar esteja acima do conteúdo da barra lateral */ } + + footer { + margin-top: auto; /* Empurra o rodapé para a parte inferior da barra lateral */ + padding: 10px; + color: #ffffff; + } } /* Ajustes para telas menores que 576px */ @media (max-width: 575.98px) { .btn-login { - display: flex; - align-items: center; + display: none; } - .btn-login .bi-person-fill { - margin-right: 0; /* Remove a margem à direita quando o nome de usuário está escondido */ + .dropdown-toggle::after { + display: none; /* Remove o ícone de seta padrão do dropdown */ } - .btn-login span { - display: none; /* Esconde o nome de usuário em telas pequenas */ + .profile-photo { + cursor: pointer; + width: 43px; + height: 43px; + border-radius: 50%; + margin-right: 10px; + } + + .dropdown-menu { + display: none; /* Oculta o dropdown inicialmente */ + position: absolute; + top: 55px; + right: 5px; + } + + .dropdown-menu.show { + display: block; /* Mostra o dropdown quando ativado */ } } @@ -237,16 +303,13 @@ body, html { .forum-header { position: sticky; top: 0; - padding-bottom: 40px; - padding-top: 10px; - padding-left: 20px; - padding-right: 3px; + padding: 0px !important; margin-bottom: 0px; width: 100%; /* Certifica-se de que a linha ocupe toda a largura disponível */ display: flex; justify-content: flex-start; align-items: center; - background-color: #ffffff; + background-color: #f5f5f5 !important; } .forum-title { @@ -276,21 +339,6 @@ body, html { outline: none; } -.btn-question { - background-color: #003366; - color: #ffffff; - font-weight: bolder; - border: none; - padding: 10px 20px; - border-radius: 20px; - margin-left: 0; -} - -.btn-question:hover { - background-color: #02305e; -} - - .questions-container { overflow-y: auto; padding-right: 10px; @@ -307,7 +355,7 @@ body, html { flex-direction: column; word-wrap: break-word; /* Quebra palavras longas */ overflow-wrap: break-word; /* Garante que o texto não transborde */ - background-color: #ffffff; + background-color: #f5f5f5; transition: background-color 0.3s ease; border-radius: 5px; } @@ -320,6 +368,13 @@ body, html { text-decoration: none; } +.profile-picture { + width: 25px; + height: 25px; + border-radius: 50%; + margin-right: 5px; +} + .question-item .btn-info, .btn-like { background-color: #a0b0c0; border: none; @@ -415,6 +470,37 @@ a { margin-right: 10px !important; } +.forum-buttons { + display: flex; + flex-direction: row; + justify-content: flex-start; + background-color: #f5f5f5 !important; + margin-top: 5px; + margin-bottom: 20px; +} + +.btn-question { + background-color: #003366; + color: #ffffff; + font-weight: bolder; + font-size: 1rem !important; + border: none; + padding: 10px 20px; + border-radius: 20px; + margin-left: 0; +} + +.btn-question:hover { + background-color: #a0b0c0; + color: #003366; +} + +.dropdown-toggle:focus,/* Quando o botão estiver ativo */ +.dropdown-toggle.show { + background-color: #a0b0c0; + color: #003366; +} + /* Ajustes para telas menores que 992px */ @media (max-width: 992px) { .forum-header { @@ -435,8 +521,6 @@ a { } .btn-question { - width: auto; - align-self: flex-start; padding: 8px 16px; /* Reduz o padding do botão */ font-size: 0.9rem; /* Reduz o tamanho da fonte do botão */ } @@ -446,7 +530,7 @@ a { } .question-description, .answer-text { - font-size: 1rem; + font-size: 1.1rem; display: flex; flex-direction: column; } @@ -476,11 +560,6 @@ a { font-size: 1.5rem; } - .btn-question { - width: auto; - align-self: flex-start; - } - .question-title { font-size: 1.1rem; } @@ -528,7 +607,7 @@ html, body { flex-direction: column; word-wrap: break-word; /* Quebra palavras longas */ overflow-wrap: break-word; /* Garante que o texto não transborde */ - background-color: #ffffff; + background-color: #f5f5f5; transition: background-color 0.3s ease; border-radius: 5px; } @@ -551,17 +630,28 @@ html, body { .question-info { position: sticky; top: 0; - background-color: #ffffff; + background-color: #f5f5f5; z-index: 100; padding: 0px; } +.img-question { + min-height: 400px; + max-width: 400px; +} + +@media (max-width: 767px) { + .img-question{ + min-height: 300px; + max-width: 300px; + } +} + .response-area { margin-top: 5px; margin-bottom: 30px; width: 100%; box-sizing: border-box; - overflow: auto; } .response-area input { @@ -582,6 +672,10 @@ html, body { outline: none; box-shadow: none; } +.form-check-label{ + color: #003366; + font-weight: bold; +} .response-form { margin-top: 10px; @@ -610,7 +704,7 @@ html, body { .response-form .btn { margin-top: 10px; - margin-right: 10px; + margin-right: 5px; } .response-form .btn-send { @@ -620,23 +714,27 @@ html, body { padding: 10px 20px; border-radius: 20px; font-weight: bold; + font-size: large; } .response-form .btn-send:hover { background-color: #004c26; + color: white; } .response-form .btn-cancel { - background-color: #C7D2DD; - color: #003366; + background-color: #003366; + color: white; font-weight: bold; border: none; padding: 10px 20px; border-radius: 20px; + font-size: large; } .response-form .btn-cancel:hover { - background-color: #a0b0c0; + background-color: #012141; + color: white; } .return-container { @@ -662,7 +760,9 @@ html, body { .answer-list { margin-top: 5px; max-height: 100%; - overflow-y: auto; + background-color: #f5f5f5 !important; + padding: 1rem; + border-radius: 8px; } .answer-item { @@ -671,7 +771,7 @@ html, body { flex-direction: column; word-wrap: break-word; overflow-wrap: break-word; - background-color: #ffffff; + background-color: #f5f5f5; transition: background-color 0.3s ease; border-radius: 5px; } @@ -708,6 +808,128 @@ html, body { margin-bottom: 5px; } +/* Estilos adicionais para o Cropper.js */ +.img-container { + max-width: 100%; +} + +.img-preview { + overflow: hidden; + margin: 10px; + border: 1px solid #ccc; +} + +.preformatted-text { + white-space: pre-wrap; /* Preserva a formatação original e as quebras de linha */ + word-wrap: break-word; +} + +/* Estilos para o spinner */ +#loading-spinner { + display: none; + text-align: center; + padding: 10px; +} + +#reportModal { + display: none; + position: fixed; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 80vw; + max-width: 400px; + height: 60vh; + max-height: 400px; + border-radius: 20px; +} + +#reportModal .modal-content { + position: relative; + padding: 20px; +} + +#reportModal .close { + position: absolute; + top: 10px; + right: 10px; + color: #aaa; + font-size: 24px; + font-weight: bold; + cursor: pointer; +} + +#reportModal .close:hover, +#reportModal .close:focus { + color: rgb(255, 255, 255); + text-decoration: none; + cursor: pointer; +} + +.modal-header { + margin-bottom: 15px; +} + +.modal-body { + margin-bottom: 15px; +} + +.modal-footer { + text-align: right; +} + +.btn_denuncia{ + background-color:#ff0000; + color: #ffffff; + font-weight: bold; + border: none; + padding: 3px 8px; + border-radius: 20px; +} + +.btn_denuncia:hover{ + background-color: #9a0303; + color: #ffffff; +} + +.modal-content{ + background-color: #003366; + color: #ffffff; + border-radius: 20px; +} + +.btn-alert { + background-color: #a0b0c0; + border: none; + color: #003366; + padding: 6px 12px; + border-radius: 20px; +} + +.btn-alert:hover { + background-color: #C7D2DD; + color: #003366; +} + +textarea.form-control { + border-radius: 20px; + border-color: #ced4da; + padding: 10px; + font-size: 14px; + background-color: #a0b0c0 ; +} + +textarea.form-control:focus { + border-color: #80bdff; + box-shadow: 0 0 5px rgba(128, 189, 255, 0.5); +} + +@media (max-width: 767px) { + .form-check{ + font-size: small; + } +} + /* INDEX.HTML */ @@ -722,7 +944,7 @@ html, body { word-wrap: break-word; word-break: break-word; overflow: auto; - background-color: #ffffff; + background-color: #f5f5f5 !important; } .home-head { @@ -819,7 +1041,7 @@ body, html { } .forum-header { - background-color: #fff; + background-color: #f5f5f5; padding: 10px; padding-bottom: 20px; position: sticky; @@ -836,4 +1058,314 @@ body, html { overflow-y: auto; padding: 10px; padding-top: 0px !important; -} \ No newline at end of file +} + + + + +/* QUESTIONS.HTML */ +.full-height { + height: 100vh; /* Altura total da viewport */ + display: flex; + flex-direction: column; +} + +.row { + flex-grow: 1; +} + +.questions-list, .answers-list { + font-weight: bolder; + border-radius: 8px; +} + +.list-group-item { + margin-bottom: 10px; + border-radius: 20px; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: bold; +} + +.scroll-container { + flex-grow: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* Internet Explorer and Edge */ +} + +.scroll-container::-webkit-scrollbar { + display: none; /* Chrome, Safari, and Opera */ +} + +.list-header { + position: sticky; + top: 0; + z-index: 100; + color: #003366; + background-color: #f5f5f5; + font-weight: bold; + font-size: 2rem; +} + +@media (max-width: 768px) { + .scroll-container { + flex-grow: 1; + min-height: 0; + overflow-y: auto; + } + + .questions-list, .answers-list { + width: 100%; + } + + .list-header { + z-index: 100; + font-size: 1.5rem; + } +} + +.custom-list-group-item { + background-color: #003366; +} + +.custom-list-group-item:hover { + background-color: #012141; +} + +.custom-list-group-item a { + color: white; +} + +.btn-excluir{ + background-color:#c61919; + color: white; + border: none; + padding: 5px 10px; + border-radius: 20px; + font-weight: bold; +} + +.btn-excluir:hover{ + background-color: #ff0101; +} + +.btn-cancel { + background-color: #C7D2DD; + color: #003366; + font-weight: bold; + border: none; + padding: 5px 10px; + border-radius: 20px; +} + +.btn-cancel:hover { + background-color: #a0b0c0; +} + +.confirm-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + padding: 20px; + border: 1px solid #ccc; + border-radius: 20px; + z-index: 1000; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +} + +.confirm-popup-content { + text-align: center; + color: #003366; +} + +.hidden { + display: none; +} + +.confirm-popup button { + margin: 5px; +} + +.alerta{ + color: white; + background-color: #003366; + border-radius: 20px; +} + + +/*NEW_QUESTIONS.HTML*/ +.nome { + color: #003366; +} + +.btn-cancelar { + background-color: #003366; + color: #ffffff; + font-weight: bold; + border: none; + padding: 10px 20px; + border-radius: 20px; +} + +.btn-cancelar:hover { + background-color: #a0b0c0; +} + +.btn-enviar { + background-color: #006633; + color: white; + border: none; + padding: 10px 20px; + border-radius: 20px; + font-weight: bold; +} + +.btn-enviar:hover { + background-color: #004c26; +} + +.response-form { + max-width: 50vw; + height: 70vh; + width: 100%; +} + +.input-title { + width: 50%; + font-size: 1.25rem; + /* Tamanho do texto maior */ + color: #003366; + /* Cor do texto digitado */ +} + +.cancel { + color: white; +} + +.texto { + background-color: #C7D2DD; + color: #003366; +} + +.texto::placeholder { + color: #003366; + /* Exemplo de cor do placeholder */ + opacity: 1; + /* Garantir que a cor aplicada seja visível */ + font-weight: bold; + /* Fonte em negrito */ +} + + +.custom-label { + color: #003366; + font-weight: bold; +} + +#loading-spinner { + display: none; + text-align: center; + padding: 10px; +} + +.img-container { + width: 100%; + max-width: 400px; + /* Ajuste conforme necessário */ + max-height: 400px; + /* Ajuste conforme necessário */ + overflow: hidden; + margin: 0 auto; + border-radius: 10px; + /* Centraliza o contêiner da imagem */ +} + +.imagem{ + color: #003366; +} + +#cropper-image { + width: 100%; + /* Ajusta a largura da imagem ao contêiner */ + height: auto; + /* Mantém a proporção da imagem */ +} + +.img-preview { + width: 200px; + height: 200px; + overflow: hidden; + margin: 10px; + border: 10px solid #ccc; +} + +.img-container, .img-preview { +display: none; /* Esconde a área de visualização por padrão */ +} + + +@media (max-width: 767px) { + .response-form { + max-width: 100vw; + height: 100vh; + width: 100%; + } + + .img-container { + width: 80%; + max-width: 400px; + /* Ajuste conforme necessário */ + max-height: 400px; + /* Ajuste conforme necessário */ + overflow: hidden; + margin: 0 auto; + border-radius: 10px; + /* Centraliza o contêiner da imagem */ +} +} + +.MathJax { + font-family: 'Arial', sans-serif; + /* Substitua 'Arial' pela fonte desejada */ +} + + +#id_description_ifr { + background-color: #C7D2DD; + border-radius: 5px; +} + +.tox .tox-toolbar__primary .tox-tbtn, +.tox .tox-toolbar__primary .tox-tbtn__select-label, +.tox .tox-toolbar__primary .tox-tbtn__icon-wrap svg { + color: #000000; /* Cor dos ícones e textos */ +} + +.tox .tox-toolbar__primary .tox-tbtn:hover { + background-color:#C7D2DD; /* Cor de fundo ao passar o mouse */ +} + +.tox .tox-toolbar__primary .tox-tbtn--enabled { + background-color:#C7D2DD; /* Cor de fundo quando o botão está ativado */ + color: #fff; /* Cor do texto quando o botão está ativado */ +} +.scroll-container::-webkit-scrollbar { +display: none; /* Oculta a barra de rolagem */ +} + +.btn-imagem { + color: #003366; + background-color: #C7D2DD; + font-weight: bold; + padding: 10px 20px; + border-radius: 20px; +} +.btn-imagem:hover, .form-control:hover { + background-color: #a0b0c0; + color: #003366; +} diff --git a/forunb/static/forunb/img/default.jpg b/forunb/static/forunb/img/default.jpg new file mode 100644 index 00000000..6f6883bb Binary files /dev/null and b/forunb/static/forunb/img/default.jpg differ diff --git a/forunb/static/forunb/img/logo3.jpg b/forunb/static/forunb/img/logo3.jpg new file mode 100644 index 00000000..40fa39a8 Binary files /dev/null and b/forunb/static/forunb/img/logo3.jpg differ diff --git a/forunb/templates/base.html b/forunb/templates/base.html index c00eaf67..5f4cb78b 100644 --- a/forunb/templates/base.html +++ b/forunb/templates/base.html @@ -1,12 +1,6 @@ <!DOCTYPE html> <html lang="en"> -<style> - .faleconosco{ - font-size: 0.7rem; -} -</style> - <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> @@ -44,8 +38,8 @@ Posts</a> </nav> <footer class="mt-auto"> + <p class="text-white">Fale conosco : forunb24@gmail.com</p> <p class="text-white">© 2024 Meu Fórum</p> - <p class="faleconosco text-white ">Fale conosco : forunb24@gmail.com</p> </footer> </div> <!-- Conteúdo principal --> @@ -68,12 +62,16 @@ {% if user.is_authenticated %} <button class="btn btn-login d-none d-sm-flex align-items-center dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"> - <i class="bi bi-person-fill me-2"></i> {{ user.username }} - </button> - <button class="btn btn-login d-flex d-sm-none dropdown-toggle" type="button" - data-bs-toggle="dropdown" aria-expanded="false"> - <i class="bi bi-person-fill"></i> + <img class="profile-photo" + src="{% if user.photo %}{{ user.photo.url }}{% else %}{% static 'forunb/img/default.jpg' %}{% endif %}" + alt="profile-picture"> + <span class="username">{{ user.username }}</span> </button> + <img class="profile-photo d-sm-none dropdown-toggle" + src="{% if user.photo %}{{ user.photo.url }}{% else %}{% static 'forunb/img/default.jpg' %}{% endif %}" + onclick="toggleDropdown()" + onkeydown="if(event.key === 'Enter' || event.key === ' ') { toggleDropdown(); }" + tabindex="0" alt="Foto de perfil"> <ul class="dropdown-menu dropdown-menu-end"> <li><a class="dropdown-item" href="{% url 'users:profile' %}"><i class="bi bi-person-circle"></i>Perfil</a></li> @@ -108,27 +106,29 @@ }); document.addEventListener('DOMContentLoaded', function () { - const searchBar = document.getElementById('search-bar'); + const searchBar = document.getElementById('search-bar'); - function setPlaceholder() { - if (searchBar.closest('form').querySelector('input[name="forum_id"]')) { - // Se estivermos em uma página de fórum específico - const forumTitle = searchBar.closest('form').querySelector('input[name="forum_id"]').dataset.forumTitle; - searchBar.placeholder = `Pesquisar em ${forumTitle}`; - } else { - // Para todas as outras páginas - searchBar.placeholder = "Digite o nome da matéria"; - } - } + function setPlaceholder() { + if (searchBar.closest('form').querySelector('input[name="forum_id"]')) { + // Se estivermos em uma página de fórum específico + const forumTitle = searchBar.closest('form').querySelector('input[name="forum_id"]').dataset.forumTitle; + searchBar.placeholder = `Pesquisar em ${forumTitle}`; + } else { + // Para todas as outras páginas + searchBar.placeholder = "Digite o nome da matéria"; + } + } - searchBar.placeholder = "Pesquisar"; - - searchBar.addEventListener('focus', setPlaceholder); + searchBar.placeholder = "Pesquisar"; + searchBar.addEventListener('focus', setPlaceholder); + searchBar.addEventListener('blur', function () { + searchBar.placeholder = "Pesquisar"; + }); + }); - searchBar.addEventListener('blur', function () { - searchBar.placeholder = "Pesquisar"; - }); -}); + function toggleDropdown() { + document.querySelector('.dropdown-menu').classList.toggle('show'); + } </script> diff --git a/forunb/users/README.md b/forunb/users/README.md new file mode 100644 index 00000000..087f4473 --- /dev/null +++ b/forunb/users/README.md @@ -0,0 +1,39 @@ +# Users App +## Descrição + +Este é o app `Users` do projeto **ForUnB**. Ele gerencia as funcionalidades relacionadas a autenticação de usuários, como registro, login e perfil. + +## Funcionalidades +- **Registro de Usuário:** Permite que novos usuários se registrem no sistema. +- **Login:** Permite que os usuários existentes façam login. +- **Perfil de Usuário:** Permite que os usuários visualizem e editem seus perfis. + +## Descrição dos Arquivos +- **admin.py:** Configurações do painel administrativo do Django para gerenciar usuários. +- **apps.py:** Configurações do app Users. +- **forms.py:** Define formulários personalizados para registro e edição de perfis. +- **models.py:** Define os modelos relacionados aos usuários. +- **views.py:** Contém as views que tratam as requisições para registro, login, e perfil. +- **urls.py:** Contém as urls utilizadas no app Users. +- **templates/users/:** Contém os templates HTML para as páginas de login, registro e perfil. + +## Como Usar: +### Registro +Para permitir que novos usuários se registrem, utilize a view de register. O template associado é o register.html e o register_unb_email.html. + +### Login +Os usuários podem acessar o sistema através da view de login. O template utilizado é o login.html. + +### Perfil +Os usuários podem visualizar e editar seus perfis através da view de profile, que utiliza o template profile.html. + +## Testes: +Os testes automatizados para este app podem ser encontrados em tests. Execute-os com: +```bash +python manage.py test users +``` + +## Licença +Este projeto está licenciado sob os termos da licença MIT. + +Esse arquivo `README.md` fornece uma visão geral do app `Users` e como ele se encaixa no projeto 'ForUnB'. Ele também inclui informações sobre estrutura e uso. \ No newline at end of file diff --git a/forunb/users/admin.py b/forunb/users/admin.py index 4fb7e57c..10b53553 100644 --- a/forunb/users/admin.py +++ b/forunb/users/admin.py @@ -24,7 +24,7 @@ class CustomUserAdmin(UserAdmin): { 'fields': ( 'username', 'followed_forums', 'created_questions', - 'created_answers', 'liked_questions', 'liked_answers' + 'created_answers', 'display_liked_questions', 'display_liked_answers' ) } ), @@ -47,9 +47,23 @@ class CustomUserAdmin(UserAdmin): }), ) + readonly_fields = ('display_liked_questions', 'display_liked_answers') search_fields = ('email', 'username') ordering = ('email',) + + def display_liked_questions(self, obj): + """ Return a string with the titles of the questions liked by the. """ + return ", ".join([q.title for q in obj.upvoted_questions.all()]) + display_liked_questions.short_description = 'Liked Questions' + + def display_liked_answers(self, obj): + """ + Return a string with the first + 50 characters of the text of the answers liked by the user. """ + return ", ".join([a.text[:50] for a in obj.upvoted_answers.all()]) + display_liked_answers.short_description = 'Liked Answers' + def get_form(self, request, obj=None, **kwargs): """Customize form to include the 'followed_forums' field with appropriate queryset.""" form = super().get_form(request, obj, **kwargs) @@ -61,7 +75,8 @@ def get_readonly_fields(self, request, obj=None): if obj: # Se o objeto existe, estamos na página de edição return ( 'email', 'username', 'date_joined', 'created_questions', - 'created_answers', 'is_active', 'is_staff' + 'created_answers', 'is_active', 'is_staff', + 'display_liked_questions', 'display_liked_answers' ) return super().get_readonly_fields(request, obj) diff --git a/forunb/users/forms.py b/forunb/users/forms.py index d1468479..1d197c34 100644 --- a/forunb/users/forms.py +++ b/forunb/users/forms.py @@ -53,7 +53,7 @@ class CustomUserChangeForm(UserChangeForm): class Meta: # pylint: disable=C0115, R0903 model = CustomUser fields = ( - 'email', 'username', 'followed_forums', 'liked_questions', + 'email', 'username', 'followed_forums', 'liked_questions', 'liked_answers', 'created_questions', 'created_answers', 'is_active', 'is_staff' ) field_classes = { diff --git a/forunb/users/models.py b/forunb/users/models.py index 124d833f..ed268d87 100644 --- a/forunb/users/models.py +++ b/forunb/users/models.py @@ -2,6 +2,7 @@ from django.db import models from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from cloudinary.models import CloudinaryField class CustomUserManager(BaseUserManager): @@ -29,7 +30,8 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): email = models.EmailField(unique=True) username = models.CharField(max_length=100, unique=True, blank=True, null=True) - photo = models.ImageField(upload_to='media/profile_pics/', blank=True, null=True) + photo = CloudinaryField('image', blank=True, null=True) + # photo = models.ImageField(upload_to='media/profile_pics/', blank=True, null=True) Usar Localmente!!!! followed_forums = models.ManyToManyField('main.Forum', blank=True, related_name='followers') liked_answers = models.ManyToManyField('main.Answer', blank=True, related_name='liked_by') liked_questions = models.ManyToManyField('main.Question', blank=True, related_name='liked_by') diff --git a/forunb/users/templates/users/login.html b/forunb/users/templates/users/login.html index 9033ceb0..cca6517b 100644 --- a/forunb/users/templates/users/login.html +++ b/forunb/users/templates/users/login.html @@ -75,7 +75,7 @@ <body> <div class="text-center"> - <img class="mt-3" src="{% static 'forunb/img/logo2.jpg' %}" alt="logo" width="380" height="280"> + <img class="mt-3" src="{% static 'forunb/img/logo3.jpg' %}" alt="logo" width="380" height="280"> </div> <form method="post" action="{% url 'users:login' %}" class="px-4 row justify-content-center align-items-center" novalidate> diff --git a/forunb/users/templates/users/profile.html b/forunb/users/templates/users/profile.html index 277c0db6..21251abe 100644 --- a/forunb/users/templates/users/profile.html +++ b/forunb/users/templates/users/profile.html @@ -5,6 +5,52 @@ <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.js"></script> <style> + .container { + max-width: 1000px; + margin: 0 auto; + padding: 20px; + } + .profile-container { + max-height: 80vh; + overflow-y: auto; + } + .profile-header { + text-align: center; + } + .profile-container .form-label { + color: #003366; + font-weight: bold; + margin-bottom: 0px; + margin-top: 5px; + } + .form-control, .form-control[readonly], .email { + background-color: #C7D2DD; + color: #003366; + font-weight: bolder; + border: none; + padding: 10px 20px; + padding-right: 10px; + border-radius: 20px; + flex-grow: 1; + position: relative; + margin-bottom: 15px; + } + .form-control:focus, .email:focus { + outline: none; + box-shadow: none; + background-color: #C7D2DD; + } + .form-control::placeholder, .email::placeholder { + color: #003366; + font-weight: bolder; + font-size: 1.1rem; + position: absolute; + } + .mb-3 { + display: flex; + flex-direction: column; + margin-bottom: 1rem; + } .img-container { max-width: 100%; max-height: 400px; @@ -14,12 +60,10 @@ justify-content: center; align-items: center; } - #cropper-image { width: 100%; height: auto; } - .img-preview { width: 150px; height: 150px; @@ -27,68 +71,108 @@ margin: 10px; border: 1px solid #ccc; display: inline-block; + display: none; + } + .btn-image { + color: #003366; + background-color: #C7D2DD; + font-weight: bold; + padding: 10px 20px; + border-radius: 20px; + } + .btn-image:hover, .form-control:hover { + background-color: #a0b0c0; + color: #003366; + } + .btn-cancel { + color: white; + background-color: #810101; + font-weight: bolder; + border: none; + padding: 10px 20px; + border-radius: 20px; + margin-bottom: 10px; + font-size: large; + transition: background-image 0.3s ease, transform 0.3s ease; + margin-right: 5px; + } + .btn-cancel:hover { + background-image: linear-gradient(45deg, #810101, #970101) !important; + transform: scale(1.05) !important; + color: white; + } + .btn-home { + width: auto; + padding: 10px 20px; + font-size: large; + margin-left: 5px; + color: white; + } + #file-name { + margin-left: 10px; + color: #003366; + font-weight: bolder; } - #loading-spinner { display: none; text-align: center; padding: 10px; } - - .profile-container { - max-height: 80vh; /* Altura máxima da área do perfil */ - overflow-y: auto; /* Rolagem vertical */ + #loading-spinner p { + font-size: 1.2rem; + color: #003366; } </style> - <div class="container mt-5 profile-container"> <div class="row justify-content-center"> <div class="col-md-8"> - <div class="card"> - <div class="card-header">Perfil</div> - <div class="card-body"> - <div class="text-center mb-3"> - {% if user.photo %} - <img src="{{ user.photo.url }}" class="rounded-circle" style="width: 150px; height: 150px; background-color: #ccc;"> - {% else %} - <div class="rounded-circle" style="width: 150px; height: 150px; background-color: #ccc;"></div> - {% endif %} + <div class="profile-header text-center mb-4"> + {% if user.photo %} + <img src="{{ user.photo.url }}" class="rounded-circle" style="width: 150px; height: 150px; background-color: #ccc;"> + {% else %} + <div class="rounded-circle" style="width: 150px; height: 150px; background-color: #ccc;"></div> + {% endif %} + </div> + <form method="post" action="{% url 'users:edit_profile' %}" enctype="multipart/form-data" onsubmit="return submitForm(event)"> + {% csrf_token %} + <div class="mb-3"> + <label class="form-label">Nome de usuário</label> + <input type="text" name="username" class="form-control" value="{{ user.username }}" data-original-username="{{ user.username }}"> + </div> + <div class="mb-3"> + <label class="form-label">Foto de Perfil</label> + <div class="custom-file-container"> + <input type="file" name="photo" id="photo" accept="image/*" onchange="showCropper(event)" style="display: none;"> + <button type="button" class="btn btn-image" onclick="document.getElementById('photo').click();">Escolher Arquivo</button> + <span id="file-name"></span> </div> - <form method="post" action="{% url 'users:edit_profile' %}" enctype="multipart/form-data" onsubmit="return submitForm(event)"> - {% csrf_token %} - <div class="mb-3"> - <label class="form-label">Nome de usuário</label> - <input type="text" name="username" class="form-control" value="{{ user.username }}"> - </div> - <div class="mb-3"> - <label class="form-label">Foto de Perfil</label> - <input type="file" name="photo" class="form-control-file" id="photo" accept="image/*" onchange="showCropper(event)"> - <div class="img-container mt-3"> - <img id="cropper-image"> - </div> - <div class="img-preview mt-3"></div> - </div> - <div class="mb-3"> - <label class="form-label">Email</label> - <input type="text" class="form-control" value="{{ user.email }}" readonly> - </div> - <button type="submit" class="btn btn-primary">Salvar</button> - <div id="loading-spinner"> - <p>Carregando...</p> - </div> - </form> - {% if messages %} - <div class="alert alert-dismissible fade show" role="alert"> - {% for message in messages %} - <div class="alert alert-{{ message.tags }}"> - {{ message }} - </div> - {% endfor %} - <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> + <div class="img-container mt-3"> + <img id="cropper-image"> + </div> + <div class="img-preview mt-3"></div> + </div> + <div class="mb-3"> + <label class="form-label">Email</label> + <input type="text" class="email" value="{{ user.email }}" readonly> + </div> + <div class="d-flex justify-content-center" id="action-buttons" style="display: none;"> + <button type="button" class="btn btn-cancel" onclick="cancelSelection()">Cancelar</button> + <button type="submit" class="btn btn-home">Salvar</button> + </div> + <div id="loading-spinner"> + <p>Carregando...</p> + </div> + </form> + {% if messages %} + <div class="alert alert-dismissible fade show" role="alert"> + {% for message in messages %} + <div class="alert alert-{{ message.tags }}"> + {{ message }} </div> - {% endif %} + {% endfor %} + <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> </div> - </div> + {% endif %} </div> </div> </div> @@ -98,13 +182,20 @@ const image = document.getElementById('cropper-image'); function showCropper(event) { - const files = event.target.files; + const files = event.target.files; + const fileNameElement = document.getElementById('file-name'); + const cancelButton = document.getElementById('cancel-button'); + if (files && files.length > 0) { const file = files[0]; + fileNameElement.textContent = file.name; // Mostra o nome do arquivo + const validImageTypes = ['image/jpeg', 'image/png', 'image/gif']; if (!validImageTypes.includes(file.type)) { alert('Por favor, selecione uma imagem válida (JPEG, PNG, GIF).'); event.target.value = ''; // Limpa o input + fileNameElement.textContent = ''; // Limpa o nome do arquivo + cancelButton.style.display = 'none'; // Oculta o botão de cancela return; } @@ -119,11 +210,75 @@ viewMode: 1, preview: '.img-preview', }); + // Torna visível a área de visualização e a pré-visualização da imagem + document.querySelector('.img-container').style.display = 'flex'; + document.querySelector('.img-preview').style.display = 'block'; + cancelButton.style.display = 'inline-block'; }; reader.readAsDataURL(file); } } + document.addEventListener('DOMContentLoaded', function() { + const usernameInput = document.querySelector('input[name="username"]'); + const photoInput = document.getElementById('photo'); + const actionButtons = document.getElementById('action-buttons'); + let originalUsername = usernameInput.value; // Mover para o escopo correto + + // Monitora mudanças no campo de nome de usuário + usernameInput.addEventListener('input', function() { + if (usernameInput.value !== originalUsername || photoInput.files.length > 0) { + actionButtons.style.display = 'flex'; + } else { + actionButtons.style.display = 'none'; + } + }); + + // Monitora mudanças no campo de seleção de arquivos + photoInput.addEventListener('change', function(event) { + if (event.target.files.length > 0) { + showCropper(event); + actionButtons.style.display = 'flex'; + } else if (usernameInput.value === originalUsername) { + actionButtons.style.display = 'none'; + } + }); + }); + + function cancelSelection() { + // Limpa o input de arquivo + const fileInput = document.getElementById('photo'); + fileInput.value = ''; // Reseta o valor do input de arquivo + + // Oculta a área de visualização da imagem e o botão de cancelar + document.querySelector('.img-container').style.display = 'none'; + document.querySelector('.img-preview').style.display = 'none'; + document.getElementById('file-name').textContent = ''; // Limpa o nome do arquivo exibido + + // Remove a imagem de pré-visualização + document.getElementById('cropper-image').src = ''; + + // Destrói a instância do Cropper se ela existir + if (cropper) { + cropper.destroy(); + cropper = null; + } + + // Reseta o nome de usuário para o valor original + const usernameInput = document.querySelector('input[name="username"]'); + const originalUsername = usernameInput.getAttribute('data-original-username'); + usernameInput.value = originalUsername; + + // Verifica se o nome de usuário foi alterado ou se a imagem foi selecionada + const actionButtons = document.getElementById('action-buttons'); + if (usernameInput.value === originalUsername && fileInput.files.length === 0) { + actionButtons.style.display = 'none'; + } + + // Redireciona para a página "index" + window.location.href = "{% url 'main:index' %}"; + } + function submitForm(event) { event.preventDefault(); const form = event.target; @@ -154,7 +309,7 @@ .then(response => response.json()) .then(data => { document.getElementById('loading-spinner').style.display = 'none'; - location.href = '{% url "users:profile" %}'; + location.href = '{% url "main:index" %}'; }) .catch(error => { document.getElementById('loading-spinner').style.display = 'none'; diff --git a/forunb/users/templates/users/register_unb_email.html b/forunb/users/templates/users/register_unb_email.html index 761a2bd1..9674606f 100644 --- a/forunb/users/templates/users/register_unb_email.html +++ b/forunb/users/templates/users/register_unb_email.html @@ -76,7 +76,7 @@ <body> <div class="text-center"> - <img src="{% static 'forunb/img/logo2.jpg' %}" alt="logo" width="380" height="280"> + <img src="{% static 'forunb/img/logo3.jpg' %}" alt="logo" width="380" height="280"> </div> <form action="{% url 'users:register' %}" method="post" class="px-4 row justify-content-center align-items-center" novalidate> <div class="card rounded-3"> diff --git a/requirements.txt b/requirements.txt index 4740746c..c4c8d475 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,13 +6,17 @@ bs4==0.0.2 certifi==2024.7.4 charset-normalizer==3.3.2 click==8.1.7 +cloudinary==1.41.0 +django-cloudinary-storage==0.3.0 colorama==0.4.6 coverage==7.6.1 dill==0.3.8 Django==4.2.14 django-environ==0.11.2 +dj-database-url==2.2.0 environ==1.0 ghp-import==2.1.0 +gunicorn==23.0.0 idna==3.7 importlib_metadata==8.2.0 isort==5.13.2 @@ -30,10 +34,12 @@ paginate==0.5.6 pathspec==0.12.1 pillow==10.4.0 platformdirs==4.2.2 +psycopg2-binary==2.9.9 Pygments==2.18.0 pylint==3.2.6 pymdown-extensions==10.8.1 python-dateutil==2.9.0.post0 +python-decouple==3.8 pytz==2024.1 PyYAML==6.0.1 pyyaml_env_tag==0.1 @@ -51,4 +57,5 @@ Unidecode==1.3.2 update==0.0.1 urllib3==2.2.2 watchdog==4.0.1 +whitenoise==6.7.0 zipp==3.19.2 diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 00000000..e3d06d76 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.10.12 \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..60a6704f --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,22 @@ +## Descrição +Este script Python é utilizado para gerar um arquivo .env para um projeto Django. O arquivo .env é essencial em projetos Django, pois armazena **variáveis de ambiente**, como a chave secreta (`SECRET_KEY`), que são necessárias para a execução segura do projeto. +Este arquivo deve ser executado antes de rodar o ambiente localmente com o uso do Makefile. +```bash +make config +``` + +## Como o Script Funciona +É feito uma leitura no arquivo `.env.example` onde estão armazenados as variáveis de ambiente padrão do projeto. O script lê o conteúdo do arquivo `.env.example` e cria um novo arquivo `.env` com o mesmo ambiente de produção (local) e inicializa a chave secreta (SECRET_KEY) com um valor aleatório, que é geredo por uma biblioteca do Django. + +## Uso +Execute o script em um ambiente Python onde o Django esteja instalado. Certifique-se de que o caminho para o arquivo .env.example esteja correto, conforme definido no script. + +```bash +python config.py +``` + + +Exemplo de Saída +```bash +.env file created successfully at /2024-1forunb/forunb/.env +``` \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties index abbd6adf..445a8cb3 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -14,4 +14,4 @@ sonar.organization=unb-mds sonar.python.coverage.reportPaths=coverage.xml -sonar.exclusions=**/test_models.py, **/test_views.py \ No newline at end of file +sonar.exclusions=**/test_models.py, **/test_views.py, **/asgi.py, **/production.py, **/wsgi.py \ No newline at end of file