commit a41e383dad8d3108dc7c6be951c7559245f438d2 Author: keven Date: Tue Oct 21 18:14:51 2025 -0300 [MIR-4] feat(Initial): Cria o projeto inicial da aplicação diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0e0f35c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Normaliza finais de linha +* text=auto + +# Força Python e arquivos de configuração a usarem LF +*.py text eol=lf +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.env text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05291f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Ambiente virtual +venv/ +.env +.env.* + +# Bytecode compilado +__pycache__/ +*.py[cod] +*$py.class + +# Arquivos temporários do sistema +.DS_Store +Thumbs.db + +# Logs e databases locais +*.log +*.sqlite3 + +# VSCode +.vscode/ + +# PyCharm +.idea/ + +# Arquivos de testes ou builds +*.coverage +htmlcov/ +coverage.xml +dist/ +build/ +.eggs/ +*.egg-info/ + +# Cache do pip +pip-wheel-metadata/ +*.egg +.cache/ +.tox/ + +# Arquivo s de conexão +config/database/firebird.json +storage/temp +storage/temp.json + +# Ignorar arquivos storage +storage/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8826883 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Usa a imagem oficial do Python +FROM python:3.12-slim + +# Define diretório de trabalho no container +WORKDIR /app + +# Copia o arquivo de dependências +COPY requirements.txt . + +# Instala dependências no sistema e no Python +RUN apt-get update && apt-get install -y \ + gcc libffi-dev libssl-dev python3-dev firebird-dev \ + && pip install --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt \ + && apt-get remove -y gcc \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +# Copia o restante do projeto para o container +COPY . . + +# Expõe a porta padrão do Uvicorn/FastAPI +EXPOSE 8000 + +# Comando para iniciar o servidor +CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port 8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..1caf799 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Configuração do Projeto Python + +Este guia descreve o passo a passo para configurar o ambiente de desenvolvimento de um projeto Python, incluindo a preparação do ambiente virtual, instalação de dependências e configuração do banco de dados. + +--- + +## 1. Clonar o Projeto + +Primeiro, clone o repositório do projeto a partir do Git: + +```bash +git clone https://git.oriustecnologia.com/OriusTecnologia/Mirror.git +``` + +--- + +## 2. Criar o Ambiente Virtual + +O uso de um **ambiente virtual** garante que as bibliotecas instaladas para este projeto não afetem o Python global da sua máquina. + +```bash +python -m venv venv +``` + +--- + +## 3. Ativar o Ambiente Virtual + +Ative o ambiente virtual antes de instalar as dependências ou executar a aplicação. + +```bash +venv\Scripts\activate +``` + +> **Observação:** +> Em sistemas Unix (Linux/Mac), o comando pode ser: +> +> ```bash +> source venv/bin/activate +> ``` + +--- + +## 4. Instalar Dependências do Sistema + +A biblioteca de criptografia utilizada no projeto requer uma extensão da Microsoft para ser instalada. +Baixe e instale o **Microsoft C++ Build Tools** através do link abaixo: + +[https://visualstudio.microsoft.com/pt-br/visual-cpp-build-tools/](https://visualstudio.microsoft.com/pt-br/visual-cpp-build-tools/) + +Durante a instalação, selecione o pacote: + +``` +Desktop Development With C++ +``` + +--- + +## 5. Instalar as Bibliotecas do Projeto + +Com o ambiente virtual **ativado**, instale as dependências listadas no arquivo `requirements.txt`: + +```bash +pip install -r requirements.txt +``` + +--- + +## 6. Configurar o Banco de Dados + +O projeto utiliza um banco **Firebird**. +Edite o arquivo de configuração localizado em: + +``` +api/config/database/firebird.json +``` + +Exemplo do conteúdo padrão: + +```json +{ + "host": "localhost", + "name": "D:/Orius/Base/CAIAPONIA.FDB", + "port": 3050, + "user": "SYSDBA", + "password": "", + "charset": "UTF8", + "pool": { + "pre_ping": true, + "size": 5, + "max_overflow": 10 + } +} +``` + +### Ajustes Necessários + +* **host**: Endereço do servidor do banco de dados. +* **name**: Caminho completo do arquivo `.FDB`. +* **port**: Porta do Firebird (padrão: `3050`). +* **user**: Usuário do banco de dados. +* **password**: Senha do usuário configurado. + +--- + +## 7. Iniciar a Aplicação + +Com o ambiente virtual **ativado**, execute o comando abaixo para iniciar a aplicação: + +```bash +uvicorn main:app --reload +``` + +> **Dica:** +> O parâmetro `--reload` reinicia automaticamente a aplicação sempre que houver alterações no código. + +--- + +## 8. Testando a Aplicação + +Após iniciar a aplicação, abra o navegador e acesse o seguinte endereço: + +```http +http://localhost:8000/docs +``` + +Você deverá visualizar a interface do **Swagger**, onde estarão listados todos os endpoints disponíveis da API. + +> **Observação:** +> O Swagger permite testar os endpoints diretamente pelo navegador, sem necessidade de ferramentas externas como Postman ou Insomnia. + +--- + +## Resumo dos Comandos + +| Etapa | Comando | +| ----------------------- | ------------------------------------------------------------------------------- | +| Clonar o projeto | `git clone https://git.oriustecnologia.com/OriusTecnologia/saas_api.git` | +| Criar ambiente virtual | `python -m venv venv` | +| Ativar ambiente virtual | `venv\Scripts\activate` *(Windows)*
`source venv/bin/activate` *(Linux/Mac)* | +| Instalar dependências | `pip install -r requirements.txt` | +| Iniciar a aplicação | `uvicorn main:app --reload` | diff --git a/abstracts/action.py b/abstracts/action.py new file mode 100644 index 0000000..da31d8a --- /dev/null +++ b/abstracts/action.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + + +class BaseAction: + + """ + Classe abstrata base para todos as actions do sistema. + Obriga implementação de um método execute(). + """ + + @abstractmethod + def execute(self, *args, **kwargs): + """ + Método abstrato obrigatório a ser implementado pelas subclasses. + Deve conter a lógica principal do repositório. + """ + pass \ No newline at end of file diff --git a/abstracts/repository.py b/abstracts/repository.py new file mode 100644 index 0000000..d447580 --- /dev/null +++ b/abstracts/repository.py @@ -0,0 +1,41 @@ +# abstracts/repository.py +from sqlalchemy.orm import Session +from database.postgres import SessionLocal + + +class BaseRepository: + """Classe base para repositórios ORM.""" + + def __init__(self): + self.session: Session = SessionLocal() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type: + self.session.rollback() + else: + self.session.commit() + self.session.close() + + # 🔹 Métodos utilitários ORM + def add(self, instance): + """Adiciona um registro.""" + self.session.add(instance) + self.session.commit() + self.session.refresh(instance) + return instance + + def delete(self, instance): + """Remove um registro.""" + self.session.delete(instance) + self.session.commit() + + def all(self, model, limit: int = 100): + """Retorna todos os registros de um modelo.""" + return self.session.query(model).limit(limit).all() + + def get(self, model, pk: int): + """Busca um registro por ID.""" + return self.session.get(model, pk) diff --git a/actions/config/config.py b/actions/config/config.py new file mode 100644 index 0000000..177b551 --- /dev/null +++ b/actions/config/config.py @@ -0,0 +1,20 @@ +import json +from pathlib import Path +from types import SimpleNamespace + + +class Config: + + @staticmethod + def get(name: str): + # Caminho absoluto do arquivo atual + base_dir = Path(__file__).resolve().parent + + # Caminho absoluto para o config.json (subindo dois níveis e entrando em config/) + config_path = base_dir.parent.parent / 'config' / name + + # Carrega o JSON como objeto acessível por ponto + with open(config_path, 'r') as f: + config = json.load(f, object_hook=lambda d: SimpleNamespace(**d)) + + return config \ No newline at end of file diff --git a/actions/dynamic_import/dynamic_import.py b/actions/dynamic_import/dynamic_import.py new file mode 100644 index 0000000..fddc643 --- /dev/null +++ b/actions/dynamic_import/dynamic_import.py @@ -0,0 +1,32 @@ +import importlib +from actions.config.config import Config +from typing import Optional, Any, Type + + + +class DynamicImport: + + def __init__(self) -> None: + self.config: dict[str, Any] = Config.get("app.json") + self.base: str = "packages.v1" + self.package: Optional[str] = None + self.table: Optional[str] = None + + def set_package(self, name: str) -> None: + self.package = name + + def set_table(self, table: str): + self.table = table + + def service(self, name: str, class_name : str) -> Type[Any]: + try: + # Define o nome do Módulo + module_file = f"{name}" + # Define o caminho do arquivo + path = f"{self.base}.{self.package}.services.{self.table}.{self.config.state}.{module_file}" + # Realiza a importação do arquivo + module = importlib.import_module(path) + clazz = getattr(module, class_name) + return clazz + except (ImportError, AttributeError) as e: + raise ImportError(f"Erro ao importar '{class_name}' de '{path}': {e}") diff --git a/actions/file/file.py b/actions/file/file.py new file mode 100644 index 0000000..0915c98 --- /dev/null +++ b/actions/file/file.py @@ -0,0 +1,32 @@ +import json +import os + + +class File: + + def create(self, data, caminho_arquivo='storage/temp.json'): + try: + # Garante que a pasta existe + os.makedirs(os.path.dirname(caminho_arquivo), exist_ok=True) + + # Lê dados existentes (ou cria nova lista) + if os.path.exists(caminho_arquivo): + with open(caminho_arquivo, 'r', encoding='utf-8') as arquivo: + try: + dados_existentes = json.load(arquivo) + if not isinstance(dados_existentes, list): + dados_existentes = [] + except json.JSONDecodeError: + dados_existentes = [] + else: + dados_existentes = [] + + # Adiciona novo dado + dados_existentes.append(data) + + # Salva novamente no arquivo com indentação + with open(caminho_arquivo, 'w', encoding='utf-8') as arquivo: + json.dump(dados_existentes, arquivo, indent=4, ensure_ascii=False) + + except Exception as e: + print(f"❌ Erro ao salvar o dado: {e}") \ No newline at end of file diff --git a/actions/jwt/create_token.py b/actions/jwt/create_token.py new file mode 100644 index 0000000..caca25d --- /dev/null +++ b/actions/jwt/create_token.py @@ -0,0 +1,36 @@ +from datetime import datetime, timedelta +from jose import jwt +from pytz import timezone + +from abstracts.action import BaseAction +from actions.config.config import Config + +class CreateToken(BaseAction): + def __init__(self): + # Busca as configurações da aplicação + self.config = Config.get('app.json') + + # Cria o timedelta com base na config + self.access_token_expire = timedelta( + minutes=self.config.jwt.expire.minute, + hours=self.config.jwt.expire.hours, + days=self.config.jwt.expire.days + ) + + def execute(self, tipo_token: str, data : str) -> str: + + sp = timezone('America/Sao_Paulo') + agora = datetime.now(tz=sp) + expira = agora + self.access_token_expire + + # Define os dados do token + payload = { + 'type' : tipo_token, + 'exp' : expira, + 'iat' : agora, + 'data' : str(data) + } + + # Retorna os dados codificados + return jwt.encode(payload, self.config.jwt.token, algorithm=self.config.jwt.algorithm) + \ No newline at end of file diff --git a/actions/jwt/get_current_user.py b/actions/jwt/get_current_user.py new file mode 100644 index 0000000..e70674d --- /dev/null +++ b/actions/jwt/get_current_user.py @@ -0,0 +1,24 @@ +from fastapi import Depends, HTTPException, status, Request +from fastapi.security import OAuth2PasswordBearer + +from actions.jwt.verify_token import VerifyToken # A classe que criamos anteriormente + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # Apenas requerido pelo FastAPI + +def get_current_user(token: str = Depends(oauth2_scheme)): + + # Ação que válida o tokne + verify_token = VerifyToken() + # Obtem o resultado da validação + result = verify_token.execute(token) + + # Verifica se a resposta é diferente de inválida + if result['status'] != 'valid': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=result.get('message', 'Token inválido ou expirado'), + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Retorna apenas os dados do token + return result['payload'] diff --git a/actions/jwt/verify_token.py b/actions/jwt/verify_token.py new file mode 100644 index 0000000..8c8d935 --- /dev/null +++ b/actions/jwt/verify_token.py @@ -0,0 +1,57 @@ +from datetime import datetime +from jose import jwt, JWTError, ExpiredSignatureError +from pytz import timezone + +from actions.config.config import Config + + +class VerifyToken: + def __init__(self): + # Carrega configurações + self.config = Config.get('app.json') + + def execute(self, token: str, expected_type: str = 'access-token') -> dict: + try: + # Decodifica o token + payload = jwt.decode( + token, + self.config.jwt.token, + algorithms=[self.config.jwt.algorithm] + ) + + # Valida expiração + exp_timestamp = payload.get("exp") + if exp_timestamp is None: + raise ValueError("O token não possui data de expiração.") + + # Verifica o tipo de token + token_type = payload.get("type") + if token_type != expected_type: + raise ValueError("Tipo de token inválido.") + + # Verificação opcional: validar campo "data" + if "data" not in payload: + raise ValueError("Token malformado: campo 'data' ausente.") + + return { + "status": "valid", + "payload": payload + } + + except ExpiredSignatureError: + return { + "status": "expired", + "message": "O token expirou." + } + + except JWTError as e: + return { + "status": "invalid", + "message": f"Token inválido: {str(e)}" + } + + except Exception as e: + return { + "status": "error", + "message": f"Erro na validação do token: {str(e)}" + } \ No newline at end of file diff --git a/actions/log/log.py b/actions/log/log.py new file mode 100644 index 0000000..3fe4298 --- /dev/null +++ b/actions/log/log.py @@ -0,0 +1,32 @@ +import json +import os + + +class Log: + + def register(self, data, caminho_arquivo='storage/temp.json'): + try: + # Garante que a pasta existe + os.makedirs(os.path.dirname(caminho_arquivo), exist_ok=True) + + # Lê dados existentes (ou cria nova lista) + if os.path.exists(caminho_arquivo): + with open(caminho_arquivo, 'r', encoding='utf-8') as arquivo: + try: + dados_existentes = json.load(arquivo) + if not isinstance(dados_existentes, list): + dados_existentes = [] + except json.JSONDecodeError: + dados_existentes = [] + else: + dados_existentes = [] + + # Adiciona novo dado + dados_existentes.append(data) + + # Salva novamente no arquivo com indentação + with open(caminho_arquivo, 'w', encoding='utf-8') as arquivo: + json.dump(dados_existentes, arquivo, indent=4, ensure_ascii=False) + + except Exception as e: + print(f"❌ Erro ao salvar o dado: {e}") \ No newline at end of file diff --git a/actions/security/security.py b/actions/security/security.py new file mode 100644 index 0000000..e23c3a6 --- /dev/null +++ b/actions/security/security.py @@ -0,0 +1,43 @@ +# core/security.py + +# Importa CryptContext da biblioteca passlib para operações de hash de senha +from passlib.context import CryptContext + +# Cria uma instância do contexto de criptografia +# O esquema usado é 'bcrypt', que é seguro e amplamente aceito +# O parâmetro 'deprecated="auto"' marca versões antigas como inseguras, se aplicável +CRYPTO = CryptContext(schemes=['bcrypt'], deprecated='auto') + + +class Security: + + # Verifica se a senha tem um hash válido + @staticmethod + def is_hash(senha: str) -> bool: + """ + Verifica se a string fornecida é um hash reconhecido pelo CryptContext. + """ + return CRYPTO.identify(senha) + + + # Verifica se uma senha fornecida corresponde ao hash armazenado + def verify_senha_api(plain_senha_api: str, hashed_senha_api: str) -> bool: + """ + Compara a senha fornecida em texto puro com o hash armazenado. + + :param plain_senha_api: Senha digitada pelo usuário + :param hashed_senha_api: Hash da senha armazenado no banco de dados + :return: True se corresponder, False se não + """ + return CRYPTO.verify(plain_senha_api, hashed_senha_api) + + + # Gera o hash de uma senha fornecida + def hash_senha_api(plain_senha_api: str) -> str: + """ + Gera e retorna o hash da senha fornecida. + + :param plain_senha_api: Senha em texto puro fornecida pelo usuário + :return: Hash da senha + """ + return CRYPTO.hash(plain_senha_api) diff --git a/actions/system/exceptions.py b/actions/system/exceptions.py new file mode 100644 index 0000000..ec151c9 --- /dev/null +++ b/actions/system/exceptions.py @@ -0,0 +1,4 @@ +# exceptions.py +class BusinessRuleException(Exception): + def __init__(self, message: str): + self.message = message \ No newline at end of file diff --git a/actions/system/handlers.py b/actions/system/handlers.py new file mode 100644 index 0000000..3e2cfe4 --- /dev/null +++ b/actions/system/handlers.py @@ -0,0 +1,86 @@ +# handlers.py +import json +import traceback + +from fastapi import Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException + +from actions.system.exceptions import BusinessRuleException +from actions.log.log import Log + + +def register_exception_handlers(app): + + def __init__ (self): + log = Log() + + @app.exception_handler(BusinessRuleException) + async def business_rule_exception_handler(request: Request, exc: BusinessRuleException): + + response = { + "status": "422", + "error": "Regra de negócio", + "detail": exc.message + } + + # Salva o log em disco + Log.register(response, 'storage/temp/business_rule_exception_handler.json') + + return JSONResponse( + status_code=422, + content=response + ) + + @app.exception_handler(StarletteHTTPException) + async def http_exception_handler(request: Request, exc: StarletteHTTPException): + response = { + "status": exc.status_code, + "error": "HTTP Error", + "detail": exc.detail + } + + # Salva o log em disco + Log.register(response, 'storage/temp/http_exception_handler.json') + + return JSONResponse( + status_code=exc.status_code, + content=response + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + + response = { + "status": 400, + "error": "Erro de validação", + "detail": exc.errors() + } + + # Salva o log em disco + Log.register(response, 'storage/temp/validation_exception_handler.json') + + return JSONResponse( + status_code=400, + content=response + ) + + @app.exception_handler(Exception) + async def global_exception_handler(request: Request, exc: Exception): + + response = { + "status": 500, + "error": "Erro Interno do Servidor", + "type": type(exc).__name__, + "message": str(exc), + "trace": traceback.format_exc() + } + + # Salva o log em disco + Log.register(response, 'storage/temp/validation_exception_handler.json') + + return JSONResponse( + status_code=500, + content=response + ) diff --git a/actions/validations/cep.py b/actions/validations/cep.py new file mode 100644 index 0000000..025eefc --- /dev/null +++ b/actions/validations/cep.py @@ -0,0 +1,8 @@ + +class CEP: + + @staticmethod + def validate(data: str) -> bool: + + # Valida e retorna a informação + return len(data) == 8 \ No newline at end of file diff --git a/actions/validations/cnpj.py b/actions/validations/cnpj.py new file mode 100644 index 0000000..2d5b190 --- /dev/null +++ b/actions/validations/cnpj.py @@ -0,0 +1,35 @@ +import re + + +class CNPJ: + + @staticmethod + def validate(data: str) -> bool: + + # Remove caracteres não numéricos + data = re.sub(r'\D', '', data) + + # Verifica se tem 14 dígitos + if len(data) != 14: + return False + + # CNPJs com todos os dígitos iguais são inválidos + if data == data[0] * 14: + return False + + # Calcula os dois dígitos verificadores + def calcular_digito(data, peso): + soma = sum(int(a) * b for a, b in zip(data, peso)) + resto = soma % 11 + return '0' if resto < 2 else str(11 - resto) + + # Primeiro dígito verificador + peso1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] + digito1 = calcular_digito(data[:12], peso1) + + # Segundo dígito verificador + peso2 = [6] + peso1 + digito2 = calcular_digito(data[:12] + digito1, peso2) + + # Verifica se os dígitos batem + return data[-2:] == digito1 + digito2 diff --git a/actions/validations/cpf.py b/actions/validations/cpf.py new file mode 100644 index 0000000..2083800 --- /dev/null +++ b/actions/validations/cpf.py @@ -0,0 +1,34 @@ +import re + + +class CPF: + + @staticmethod + def is_valid_cpf(data: str) -> bool: + # Remove caracteres não numéricos + data = re.sub(r'\D', '', data) + + # Verifica se tem 11 dígitos + if len(data) != 11: + return False + + # CPFs com todos os dígitos iguais são inválidos + if data == data[0] * 11: + return False + + # Calcula o primeiro e segundo dígitos verificadores + def calcular_digito(digitos, peso): + soma = sum(int(a) * b for a, b in zip(digitos, peso)) + resto = soma % 11 + return '0' if resto < 2 else str(11 - resto) + + # Primeiro dígito verificador + peso1 = range(10, 1, -1) + digito1 = calcular_digito(data[:9], peso1) + + # Segundo dígito verificador + peso2 = range(11, 1, -1) + digito2 = calcular_digito(data[:10], peso2) + + # Verifica se os dígitos batem + return data[-2:] == digito1 + digito2 \ No newline at end of file diff --git a/actions/validations/email.py b/actions/validations/email.py new file mode 100644 index 0000000..4839b95 --- /dev/null +++ b/actions/validations/email.py @@ -0,0 +1,9 @@ +import re + + +class Email: + + @staticmethod + def is_valid_email(email: str) -> bool: + """Check if email has a valid structure""" + return bool(re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email)) \ No newline at end of file diff --git a/actions/validations/phone.py b/actions/validations/phone.py new file mode 100644 index 0000000..b509c7f --- /dev/null +++ b/actions/validations/phone.py @@ -0,0 +1,12 @@ + +class Phone: + + @staticmethod + def validate_cellphone(data: str) -> bool: + # Verifica e retorna se o numero de celular é igual a 11 + return len(data) == 11 + + @staticmethod + def validate_telephone(data: str) -> bool: + # Verifica e retorna se o numero de telefone é igual a 11 + return len(data) == 10 \ No newline at end of file diff --git a/actions/validations/text.py b/actions/validations/text.py new file mode 100644 index 0000000..51438bc --- /dev/null +++ b/actions/validations/text.py @@ -0,0 +1,63 @@ +import html +import re + + +class Text: + + # Remove as mascaras de números + @staticmethod + def just_numbers(data: str) -> str: + """ Mantêm apenas os numeros """ + data = re.sub(r"[^\d]", "", data) + return data + + # Verifica se um e-mail é válido + @staticmethod + def is_valid_email(email: str) -> bool: + """Check if email has a valid structure""" + return bool(re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email)) + + + """ + Sanitiza entradas de texto contra XSS e SQL Injection básicos. + - Remove espaços extras + - Escapa entidades HTML + - Remove padrões suspeitos de XSS e SQL Injection + - Normaliza múltiplos espaços em um só + """ + @staticmethod + def sanitize_input(data: str) -> str: + + if not data: + return data + + # 1) Remove espaços no início e no fim + data = data.strip() + + # 2) Escapa entidades HTML (< > & ") + data = html.escape(data) + + # 3) Remove múltiplos espaços seguidos + data = re.sub(r"\s+", " ", data) + + # 4) Remove tags