diff --git a/app/bot.py b/app/bot.py
index acc105d..370d43c 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -6,13 +6,15 @@ from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
from aiogram.fsm.storage.redis import RedisStorage
-from app.callbacks import menu, profile
+from app.callbacks import menu, profile, travels
from app.config import Config
from app.handlers import (
+ create_travel_command,
help_command,
menu_command,
profile_command,
start_command,
+ travels_command,
)
from app.middlewares.throttling import ThrottlingMiddleware
@@ -34,8 +36,12 @@ async def main() -> None:
help_command.router,
menu_command.router,
profile_command.router,
+ create_travel_command.router,
+ travels_command.router,
+
menu.router, # type: ignore
profile.router, # type: ignore
+ travels.router, # type: ignore
)
await bot.delete_webhook(drop_pending_updates=True)
diff --git a/app/callbacks/menu.py b/app/callbacks/menu.py
index 21fbea8..5b5ad8f 100644
--- a/app/callbacks/menu.py
+++ b/app/callbacks/menu.py
@@ -3,18 +3,25 @@ __all__ = ()
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
+from aiogram.filters import StateFilter
+from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from app import messages
+from app.config import Config
from app.filters.user import RegisteredCallback
+from app.keyboards.builders import travels_keyboard
from app.keyboards.profile import get
from app.models.user import User
+from app.states.travel import TravelCreationState
router = Router(name="menu_callback")
-@router.callback_query(F.data == "menu_profile", RegisteredCallback())
+@router.callback_query(
+ F.data == "menu_profile", RegisteredCallback(), StateFilter(None),
+)
async def profile_callback(callback: CallbackQuery) -> None:
if callback.data is None or callback.message is None:
return
@@ -39,3 +46,55 @@ async def profile_callback(callback: CallbackQuery) -> None:
await callback.message.delete()
except TelegramBadRequest:
pass
+
+
+@router.callback_query(
+ F.data == "menu_create_travel", RegisteredCallback(), StateFilter(None),
+)
+async def create_travel_callback(
+ callback: CallbackQuery,
+ state: FSMContext,
+) -> None:
+ if callback.data is None or callback.message is None:
+ return
+
+ await callback.message.answer(
+ messages.CREATE_TRAVEL,
+ )
+ await callback.message.answer(
+ messages.INPUT_TRAVEL_TITLE,
+ )
+ await state.set_state(TravelCreationState.title)
+
+ await callback.answer()
+
+ try:
+ await callback.message.delete()
+ except TelegramBadRequest:
+ pass
+
+
+@router.callback_query(
+ F.data == "menu_travels", RegisteredCallback(), StateFilter(None),
+)
+async def travels_callback(
+ callback: CallbackQuery,
+) -> None:
+ page = 0
+
+ user = User().get_user_by_telegram_id(callback.from_user.id)
+
+ travels = user.get_user_travels()
+
+ if not travels or travels == []:
+ await callback.message.answer(messages.NO_TRAVELS)
+ else:
+ pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE
+
+ await callback.message.answer(
+ messages.TRAVELS,
+ reply_markup=travels_keyboard(travels, page, pages),
+ )
+
+ await callback.message.delete()
+ await callback.answer()
diff --git a/app/callbacks/travels.py b/app/callbacks/travels.py
new file mode 100644
index 0000000..77e0e2f
--- /dev/null
+++ b/app/callbacks/travels.py
@@ -0,0 +1,40 @@
+# type: ignore
+__all__ = ()
+
+from aiogram import F, Router
+from aiogram.exceptions import TelegramBadRequest
+from aiogram.filters import StateFilter
+from aiogram.types import CallbackQuery
+
+from app import messages
+from app.config import Config
+from app.filters.user import RegisteredCallback
+from app.keyboards.builders import travels_keyboard
+from app.models.user import User
+
+
+router = Router(name="menu_callback")
+
+
+@router.callback_query(
+ F.data.startswith("travels_page"), RegisteredCallback(), StateFilter(None),
+)
+async def travels_callback(callback: CallbackQuery) -> None:
+ page = int(callback.data.replace("travels_page_", ""))
+
+ user = User().get_user_by_telegram_id(callback.from_user.id)
+
+ travels = user.get_user_travels()
+
+ if not travels or travels == []:
+ try:
+ await callback.message.edit_text(messages.NO_TRAVELS)
+ except TelegramBadRequest:
+ pass
+ else:
+ pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE
+
+ await callback.message.edit_text(
+ messages.TRAVELS,
+ reply_markup=travels_keyboard(travels, page, pages),
+ )
diff --git a/app/config.py b/app/config.py
index 831a73a..79e434b 100644
--- a/app/config.py
+++ b/app/config.py
@@ -17,3 +17,4 @@ class Config:
"REDIS_URL",
"redis://localhost:6379",
)
+ PAGE_SIZE = 6
diff --git a/app/handlers/create_travel_command.py b/app/handlers/create_travel_command.py
new file mode 100644
index 0000000..2b823de
--- /dev/null
+++ b/app/handlers/create_travel_command.py
@@ -0,0 +1,135 @@
+__all__ = ()
+
+from aiogram import F, Router
+from aiogram.filters import Command, StateFilter
+from aiogram.fsm.context import FSMContext
+from aiogram.types import Message
+
+from app import messages, session
+from app.filters.user import Registered
+from app.models.travel import Travel
+from app.states.travel import TravelCreationState
+from app.utils.states import delete_message_from_state, handle_validation_error
+
+
+router = Router(name="create_travel_command")
+
+
+@router.message(Command("create_travel"), Registered(), StateFilter(None))
+async def command_create_travel_handler(
+ message: Message,
+ state: FSMContext,
+) -> None:
+ if message.from_user is None:
+ return
+
+ await message.answer(
+ messages.CREATE_TRAVEL,
+ )
+ await message.answer(
+ messages.INPUT_TRAVEL_TITLE,
+ )
+
+ await state.set_state(TravelCreationState.title)
+
+
+@router.message(TravelCreationState.title, F.text)
+async def name_handler(
+ message: Message,
+ state: FSMContext,
+) -> None:
+ if message.text is None:
+ return
+
+ title = message.text.strip()
+
+ if title == "/cancel":
+ await message.answer(messages.ACTION_CANCELED)
+ await message.delete()
+
+ await delete_message_from_state(state, message.chat.id, message.bot)
+ await state.clear()
+
+ return
+
+ try:
+ validated_title = Travel().validate_title(key="title", value=title)
+ except AssertionError as e:
+ await handle_validation_error(message, state, e)
+
+ return
+
+ await delete_message_from_state(state, message.chat.id, message.bot)
+
+ await state.update_data(title=validated_title)
+ await state.set_state(TravelCreationState.description)
+
+ await message.answer(
+ messages.INPUT_TRAVEL_CALLBACK.format(
+ key="title",
+ value=validated_title,
+ ),
+ )
+ await message.answer(
+ messages.INPUT_TRAVEL_DESCRIPTION,
+ )
+
+
+@router.message(TravelCreationState.description, F.text)
+async def description_handler(
+ message: Message,
+ state: FSMContext,
+) -> None:
+ if message.text is None or message.from_user is None:
+ return
+
+ description = message.text.strip()
+
+ if description == "/cancel":
+ await message.answer(messages.ACTION_CANCELED)
+ await message.delete()
+
+ await delete_message_from_state(state, message.chat.id, message.bot)
+ await state.clear()
+
+ return
+
+ if description == "/skip":
+ await state.update_data(description=None)
+
+ await message.answer(messages.INPUT_TRAVEL_DESCRIPTION_SKIPPED)
+ else:
+ try:
+ validated_description = Travel().validate_description(
+ key="description",
+ value=description,
+ )
+ except AssertionError as e:
+ await handle_validation_error(message, state, e)
+
+ return
+
+ await state.update_data(description=validated_description)
+ await state.set_state(TravelCreationState.error_message_id)
+
+ await message.answer(
+ messages.INPUT_TRAVEL_CALLBACK.format(
+ key="description",
+ value=validated_description,
+ ),
+ )
+
+ await delete_message_from_state(state, message.chat.id, message.bot)
+
+ data = await state.get_data()
+ await state.clear()
+
+ if "error_message_id" in data:
+ del data["error_message_id"]
+
+ data["author_id"] = message.from_user.id
+
+ session.add(Travel(**data))
+ session.commit()
+
+ await message.answer(messages.TRAVEL_CREATED.format(title=data["title"]))
diff --git a/app/handlers/travels_command.py b/app/handlers/travels_command.py
new file mode 100644
index 0000000..d83abf2
--- /dev/null
+++ b/app/handlers/travels_command.py
@@ -0,0 +1,36 @@
+__all__ = ()
+
+from aiogram import Router
+from aiogram.filters import Command, StateFilter
+from aiogram.types import Message
+
+from app import messages
+from app.config import Config
+from app.filters.user import Registered
+from app.keyboards.builders import travels_keyboard
+from app.models.user import User
+
+
+router = Router(name="travels_command")
+
+
+@router.message(Command("travels"), Registered(), StateFilter(None))
+async def command_help_handler(message: Message) -> None:
+ page = 0
+
+ if message.from_user is None:
+ return
+
+ user = User().get_user_by_telegram_id(message.from_user.id)
+
+ travels = user.get_user_travels()
+
+ if not travels or travels == []:
+ await message.answer(messages.NO_TRAVELS)
+ else:
+ pages = (len(travels) + Config.PAGE_SIZE - 1) // Config.PAGE_SIZE
+
+ await message.answer(
+ messages.TRAVELS,
+ reply_markup=travels_keyboard(travels, page, pages),
+ )
diff --git a/app/keyboards/builders.py b/app/keyboards/builders.py
index 96fe862..5369e56 100644
--- a/app/keyboards/builders.py
+++ b/app/keyboards/builders.py
@@ -1,13 +1,76 @@
__all__ = ("sex_keyboard",)
-from aiogram.utils.keyboard import ReplyKeyboardBuilder
+from aiogram.types import InlineKeyboardButton
+from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
+
+from app.config import Config
-def sex_keyboard(text: str | list):
+def sex_keyboard(choices: str | list):
builder = ReplyKeyboardBuilder()
- if isinstance(text, str):
- text = [text]
+ if isinstance(choices, str):
+ text = [choices]
[builder.button(text=txt) for txt in text]
return builder.as_markup(resize_keyboard=True)
+
+
+def travels_keyboard(travels: list, page: int, pages: int):
+ builder = InlineKeyboardBuilder()
+ rows = []
+
+ start_index = page * Config.PAGE_SIZE
+ end_index = min((page + 1) * Config.PAGE_SIZE, len(travels))
+
+ for travel in travels[start_index:end_index]:
+ rows.append(
+ InlineKeyboardButton(
+ text=travel.title,
+ callback_data=f"travel_detail_{travel.id}",
+ ),
+ )
+
+ for _ in range(0, Config.PAGE_SIZE - len(rows)):
+ rows.append(InlineKeyboardButton(text=" ", callback_data="pass"))
+
+ builder.row(*rows, width=2)
+
+ if pages > 1:
+ navigation_row = []
+
+ if page > 0:
+ navigation_row.append(
+ InlineKeyboardButton(
+ text="⬅️", callback_data=f"travels_page_{page - 1}",
+ ),
+ )
+ else:
+ navigation_row.append(
+ InlineKeyboardButton(
+ text=" ", callback_data="pass",
+ ),
+ )
+
+ navigation_row.append(
+ InlineKeyboardButton(
+ text=f"{page + 1}/{pages}", callback_data="pass",
+ ),
+ )
+
+ if page < pages - 1:
+ navigation_row.append(
+ InlineKeyboardButton(
+ text="➡️", callback_data=f"travels_page_{page + 1}",
+ ),
+ )
+ else:
+ navigation_row.append(
+ InlineKeyboardButton(
+ text=" ", callback_data="pass",
+ ),
+ )
+
+ builder.row(*navigation_row)
+
+ return builder.as_markup()
diff --git a/app/messages.py b/app/messages.py
index 6a1452f..372102d 100644
--- a/app/messages.py
+++ b/app/messages.py
@@ -2,10 +2,34 @@
MENU = "Menu:"
+TRAVELS = "📃 Travels:"
+NO_TRAVELS = "No travels yet. You can create one with /create_travel command."
+CREATE_TRAVEL = "🧳 Let's create new travel!\nEnter /cancel to cancel creating."
+INPUT_TRAVEL_TITLE = "Enter travel title:\nMaximum length: 30 characters"
+INPUT_TRAVEL_CALLBACK = (
+ "All right, travel {key} is set to: {value}"
+)
+INPUT_TRAVEL_DESCRIPTION = (
+ "Enter travel description (enter /skip if you want to skip this step):\nMaximum length: 100 characters"
+)
+INPUT_TRAVEL_DESCRIPTION_SKIPPED = "Sure. You can always fill it later."
+TRAVEL_CREATED = "Travel {title} successfully created! You can now view and edit it in the travels list (/travels command)."
+ACTION_CANCELED = "❌ Action canceled"
+
WELCOME_MESSAGE = "Hello, {name}! Welcome to the ✈️ Travel Agent bot! Let's start our journey by filling out some information about you."
WELCOME_AGAIN_MESSAGE = "Hello, {name}! Welcome back to the ✈️ Travel Agent bot! If you get lost, you can always call the /help command for assistance."
-HELP_MESSAGE = "Help message text."
+HELP_MESSAGE = (
+ "Welcome to the ✈️ Travel Agent bot! Here is list of commands you can use:\n\n"
+ "/start - Start the bot\n"
+ "/help - Show this message\n"
+ "/menu - Show the main menu\n"
+ "/profile - View and edit your profile\n"
+ "/create_travel - Create new travel\n"
+ "/travels - View and edit your travels\n"
+ "/cancel - Cancel the current action\n\n"
+ "❓ If you have any questions/issues, feel free to contact us via @itq_travel_agent_support_bot on Telegram."
+)
REGISTERED_MESSAGE = "You have successfully registered. Welcome to the ✈️ Travel Agent bot! \nYou can view and edit your profile using the /profile command."
@@ -27,7 +51,7 @@ PROFILE = (
"\tCountry: {country}\n"
"\tCity: {city}\n"
"\tBio: {bio}\n"
- "\tDate joined: {date_joined}\n"
+ "\tDate joined: {date_joined} UTC\n"
)
NOT_SET = "Not set"
EDIT_USERNAME = "Enter your username:\nAllowed characters: a-z, A-Z, 0-9, _\nLength: 5-20 characters"
diff --git a/app/migrations/env.py b/app/migrations/env.py
index b05ea8a..c8313c2 100644
--- a/app/migrations/env.py
+++ b/app/migrations/env.py
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
from sqlalchemy import engine_from_config
from sqlalchemy import pool
-from app.models.user import Base
+from app.models import Base
load_dotenv()
diff --git a/app/migrations/versions/fe4ace4196fb_added_travel_models.py b/app/migrations/versions/fe4ace4196fb_added_travel_models.py
new file mode 100644
index 0000000..bbed24d
--- /dev/null
+++ b/app/migrations/versions/fe4ace4196fb_added_travel_models.py
@@ -0,0 +1,97 @@
+"""Added travel models
+
+Revision ID: fe4ace4196fb
+Revises: 4914f00ae14a
+Create Date: 2024-03-22 19:19:36.662090
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'fe4ace4196fb'
+down_revision: Union[str, None] = '4914f00ae14a'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ 'travels',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('title', sa.String(length=50), nullable=False),
+ sa.Column('description', sa.String(length=100), nullable=True),
+ sa.Column('author_id', sa.BigInteger(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ['author_id'],
+ ['users.telegram_id'],
+ ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('title'),
+ )
+ op.create_index(op.f('ix_travels_id'), 'travels', ['id'], unique=True)
+ op.create_table(
+ 'locations',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.Text(), nullable=False),
+ sa.Column('date_start', sa.Date(), nullable=False),
+ sa.Column('date_end', sa.Date(), nullable=False),
+ sa.Column('travel_id', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ['travel_id'],
+ ['travels.id'],
+ ),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index(op.f('ix_locations_id'), 'locations', ['id'], unique=True)
+ op.create_table(
+ 'notes',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('file_id', sa.Text(), nullable=False),
+ sa.Column('file_name', sa.Text(), nullable=False),
+ sa.Column('file_type', sa.Text(), nullable=False),
+ sa.Column('public', sa.Boolean(), nullable=False),
+ sa.Column('author_id', sa.BigInteger(), nullable=False),
+ sa.Column('travel_id', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ['author_id'],
+ ['users.telegram_id'],
+ ),
+ sa.ForeignKeyConstraint(
+ ['travel_id'],
+ ['travels.id'],
+ ),
+ sa.PrimaryKeyConstraint('id'),
+ )
+ op.create_index(op.f('ix_notes_id'), 'notes', ['id'], unique=True)
+ op.create_table(
+ 'user_travel_association',
+ sa.Column('user_id', sa.BigInteger(), nullable=True),
+ sa.Column('travel_id', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(
+ ['travel_id'],
+ ['travels.id'],
+ ),
+ sa.ForeignKeyConstraint(
+ ['user_id'],
+ ['users.telegram_id'],
+ ),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('user_travel_association')
+ op.drop_index(op.f('ix_notes_id'), table_name='notes')
+ op.drop_table('notes')
+ op.drop_index(op.f('ix_locations_id'), table_name='locations')
+ op.drop_table('locations')
+ op.drop_index(op.f('ix_travels_id'), table_name='travels')
+ op.drop_table('travels')
+ # ### end Alembic commands ###
diff --git a/app/models/__init__.py b/app/models/__init__.py
new file mode 100644
index 0000000..96b5267
--- /dev/null
+++ b/app/models/__init__.py
@@ -0,0 +1,7 @@
+# flake8: noqa
+
+from app.models.base import Base
+import app.models.user
+import app.models.travel
+
+Base.registry.configure()
diff --git a/app/models/base.py b/app/models/base.py
new file mode 100644
index 0000000..1165e43
--- /dev/null
+++ b/app/models/base.py
@@ -0,0 +1,5 @@
+from typing import Any
+
+from sqlalchemy.ext.declarative import declarative_base
+
+Base: Any = declarative_base()
diff --git a/app/models/travel.py b/app/models/travel.py
new file mode 100644
index 0000000..08223b2
--- /dev/null
+++ b/app/models/travel.py
@@ -0,0 +1,124 @@
+__all__ = ("Travel", "Location")
+
+import sqlalchemy as sa
+from sqlalchemy.orm import relationship, validates
+
+from app import session
+from app.models import Base
+from app.models.user import User
+
+
+association_table = sa.Table(
+ "user_travel_association",
+ Base.metadata,
+ sa.Column("user_id", sa.BigInteger, sa.ForeignKey("users.telegram_id")),
+ sa.Column("travel_id", sa.Integer, sa.ForeignKey("travels.id")),
+)
+
+
+class Travel(Base):
+ __tablename__ = "travels"
+
+ id = sa.Column( # noqa: A003
+ sa.Integer,
+ unique=True,
+ primary_key=True,
+ nullable=False,
+ autoincrement=True,
+ index=True,
+ )
+ title = sa.Column(
+ sa.String(50),
+ nullable=False,
+ unique=True,
+ )
+ description = sa.Column(
+ sa.String(100),
+ nullable=True,
+ )
+
+ author_id = sa.Column(
+ sa.BigInteger,
+ sa.ForeignKey(User.telegram_id),
+ nullable=False,
+ )
+
+ users = relationship(
+ User,
+ secondary=association_table,
+ backref="travels",
+ )
+
+ locations = relationship("Location", backref="travel")
+ notes = relationship("Note", backref="travel")
+
+ @validates("title")
+ def validate_title(self, key, value):
+ assert len(value) <= 30, "Title must be 30 characters or fewer."
+
+ if session.query(Travel).filter(Travel.title == value).first():
+ raise AssertionError("This title is already taken.")
+
+ return value
+
+ @validates("description")
+ def validate_description(self, key, value):
+ if value is not None:
+ assert (
+ len(value) <= 100
+ ), "Description must be 100 characters or fewer."
+
+ return value
+
+
+class Location(Base):
+ __tablename__ = "locations"
+
+ id = sa.Column( # noqa: A003
+ sa.Integer,
+ unique=True,
+ primary_key=True,
+ autoincrement=True,
+ index=True,
+ )
+ name = sa.Column(sa.Text, nullable=False)
+ date_start = sa.Column(sa.Date(), nullable=False)
+ date_end = sa.Column(sa.Date(), nullable=False)
+
+ travel_id = sa.Column(
+ sa.Integer,
+ sa.ForeignKey("travels.id"),
+ nullable=False,
+ )
+
+
+class Note(Base):
+ __tablename__ = "notes"
+
+ id = sa.Column( # noqa: A003
+ sa.Integer,
+ unique=True,
+ primary_key=True,
+ autoincrement=True,
+ index=True,
+ )
+
+ file_id = sa.Column(sa.Text, nullable=False)
+ file_name = sa.Column(sa.Text, nullable=False)
+ file_type = sa.Column(sa.Text, nullable=False)
+ public: sa.Column[bool] = sa.Column(
+ sa.Boolean(),
+ nullable=False,
+ default=False,
+ )
+
+ author_id = sa.Column(
+ sa.BigInteger,
+ sa.ForeignKey(User.telegram_id),
+ nullable=False,
+ )
+ travel_id = sa.Column(
+ sa.Integer,
+ sa.ForeignKey("travels.id"),
+ nullable=False,
+ )
diff --git a/app/models/user.py b/app/models/user.py
index a5b27cd..4da0dbe 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -1,20 +1,16 @@
__all__ = ("User",)
import re
-from typing import Any
import sqlalchemy as sa
-from sqlalchemy.ext.declarative import declarative_base
-from sqlalchemy.orm import validates
+from sqlalchemy.orm import relationship, validates
from app import session
+from app.models import Base
from app.utils import geo
from app.utils.db import utcnow
-Base: Any = declarative_base()
-
-
class User(Base):
__tablename__ = "users"
@@ -38,6 +34,9 @@ class User(Base):
server_default=utcnow(),
)
+ notes = relationship("Note", backref="author")
+ owned_travels = relationship("Travel", backref="author")
+
@validates("username")
def validate_username(self, key, value):
regex_pattern = re.compile(r"^[a-zA-Z0-9_]{5,20}$")
@@ -98,6 +97,9 @@ class User(Base):
return normalized_value
+ def get_user_travels(self):
+ return self.owned_travels + self.travels
+
def get_human_readable_datejoined(self):
return self.date_joined.strftime("%Y-%m-%d %H:%M:%S")
diff --git a/app/states/travel.py b/app/states/travel.py
new file mode 100644
index 0000000..6ed1f8b
--- /dev/null
+++ b/app/states/travel.py
@@ -0,0 +1,9 @@
+__all__ = ()
+
+from aiogram.fsm.state import State, StatesGroup
+
+
+class TravelCreationState(StatesGroup):
+ error_message_id = State()
+ title = State()
+ description = State()