wip
This commit is contained in:
parent
4be274d9e2
commit
c238b9d757
21 changed files with 219 additions and 184 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -962,3 +962,4 @@ FodyWeavers.xsd
|
||||||
# Additional files built by Visual Studio
|
# Additional files built by Visual Studio
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/vim,node,data,emacs,python,pycharm,executable,sublimetext,visualstudio,visualstudiocode
|
# End of https://www.toptal.com/developers/gitignore/api/vim,node,data,emacs,python,pycharm,executable,sublimetext,visualstudio,visualstudiocode
|
||||||
|
database.db
|
||||||
|
|
|
||||||
BIN
database.db
BIN
database.db
Binary file not shown.
|
|
@ -1,10 +1,11 @@
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from learn_sql_model.console import console
|
from learn_sql_model.console import console
|
||||||
from learn_sql_model.models import Hero, Pet
|
from learn_sql_model.models.hero import Hero
|
||||||
|
from learn_sql_model.models.pet import Pet
|
||||||
|
|
||||||
models = Union[Hero, Pet]
|
models = Union[Hero, Pet]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from typing import Annotated
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from learn_sql_model.api.user import oauth2_scheme
|
from learn_sql_model.api.user import oauth2_scheme
|
||||||
from learn_sql_model.models import Hero
|
from learn_sql_model.models.hero import Hero
|
||||||
|
|
||||||
hero_router = APIRouter()
|
hero_router = APIRouter()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import typer
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from learn_sql_model.cli.common import verbose_callback
|
from learn_sql_model.cli.common import verbose_callback
|
||||||
|
from learn_sql_model.config import config
|
||||||
|
|
||||||
api_app = typer.Typer()
|
api_app = typer.Typer()
|
||||||
|
|
||||||
|
|
@ -25,4 +26,4 @@ def run(
|
||||||
help="show the log messages",
|
help="show the log messages",
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
uvicorn.run("learn_sql_model.api.app:app", port=5000, log_level="info")
|
uvicorn.run("learn_sql_model.api.app:app", port=config.port, log_level="info")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from learn_sql_model.cli.api import api_app
|
||||||
from learn_sql_model.cli.common import verbose_callback
|
from learn_sql_model.cli.common import verbose_callback
|
||||||
from learn_sql_model.cli.config import config_app
|
from learn_sql_model.cli.config import config_app
|
||||||
from learn_sql_model.cli.hero import hero_app
|
from learn_sql_model.cli.hero import hero_app
|
||||||
from learn_sql_model.cli.model_app import model_app
|
from learn_sql_model.cli.model import model_app
|
||||||
from learn_sql_model.cli.tui import tui_app
|
from learn_sql_model.cli.tui import tui_app
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from pydantic_typer import expand_pydantic_args
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from learn_sql_model.models import Hero
|
from learn_sql_model.models.hero import Hero
|
||||||
|
|
||||||
hero_app = typer.Typer()
|
hero_app = typer.Typer()
|
||||||
|
|
||||||
|
|
|
||||||
50
learn_sql_model/cli/model.py
Normal file
50
learn_sql_model/cli/model.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from learn_sql_model.cli.common import verbose_callback
|
||||||
|
|
||||||
|
model_app = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
|
@model_app.callback()
|
||||||
|
def model(
|
||||||
|
verbose: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
callback=verbose_callback,
|
||||||
|
help="show the log messages",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
"model cli"
|
||||||
|
|
||||||
|
|
||||||
|
@model_app.command()
|
||||||
|
def create_revision(
|
||||||
|
verbose: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
callback=verbose_callback,
|
||||||
|
help="show the log messages",
|
||||||
|
),
|
||||||
|
message: str = typer.Option(
|
||||||
|
prompt=True,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
import alembic
|
||||||
|
# python -m alembic revision --autogenerate -m "New Attribute"
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
alembic_cfg = Config("alembic.ini")
|
||||||
|
alembic.command.revision(
|
||||||
|
config=alembic_cfg,
|
||||||
|
message=message,
|
||||||
|
autogenerate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@model_app.command()
|
||||||
|
def populate(
|
||||||
|
verbose: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
callback=verbose_callback,
|
||||||
|
help="show the log messages",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
...
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
from rich.console import Console
|
|
||||||
from sqlmodel import SQLModel, Session
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from learn_sql_model.cli.common import verbose_callback
|
|
||||||
from learn_sql_model.config import config
|
|
||||||
from learn_sql_model.models import Hero, Pet
|
|
||||||
|
|
||||||
model_app = typer.Typer()
|
|
||||||
|
|
||||||
|
|
||||||
@model_app.callback()
|
|
||||||
def model(
|
|
||||||
verbose: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
callback=verbose_callback,
|
|
||||||
help="show the log messages",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
"model cli"
|
|
||||||
|
|
||||||
|
|
||||||
@model_app.command()
|
|
||||||
def create_revision(
|
|
||||||
verbose: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
callback=verbose_callback,
|
|
||||||
help="show the log messages",
|
|
||||||
),
|
|
||||||
message: str = typer.Option(
|
|
||||||
prompt=True,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
import alembic
|
|
||||||
# python -m alembic revision --autogenerate -m "New Attribute"
|
|
||||||
from alembic.config import Config
|
|
||||||
|
|
||||||
alembic_cfg = Config("alembic.ini")
|
|
||||||
alembic.command.revision(
|
|
||||||
config=alembic_cfg,
|
|
||||||
message=message,
|
|
||||||
autogenerate=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@model_app.command()
|
|
||||||
def show(
|
|
||||||
verbose: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
callback=verbose_callback,
|
|
||||||
help="show the log messages",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
|
|
||||||
SQLModel.metadata.create_all(config.engine)
|
|
||||||
with Session(config.engine) as session:
|
|
||||||
heros = session.exec(select(Hero)).all()
|
|
||||||
Console().print(heros)
|
|
||||||
|
|
||||||
|
|
||||||
@model_app.command()
|
|
||||||
def read(
|
|
||||||
verbose: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
callback=verbose_callback,
|
|
||||||
help="show the log messages",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
from learn_sql_model.api import read_heroes
|
|
||||||
|
|
||||||
Console().print(read_heroes())
|
|
||||||
|
|
||||||
|
|
||||||
# @model_app.command()
|
|
||||||
# @expand_pydantic_args(typer=True)
|
|
||||||
# def create(
|
|
||||||
# hero: Hero,
|
|
||||||
# ):
|
|
||||||
# hero.post()
|
|
||||||
|
|
||||||
# try:
|
|
||||||
# httpx.post("http://localhost:5000/heroes/", json=hero.dict())
|
|
||||||
# except httpx.ConnectError:
|
|
||||||
# console.log("local failover")
|
|
||||||
# with Session(config.engine) as session:
|
|
||||||
# session.add(hero)
|
|
||||||
# session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@model_app.command()
|
|
||||||
def populate(
|
|
||||||
verbose: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
callback=verbose_callback,
|
|
||||||
help="show the log messages",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
|
|
||||||
pet_1 = Pet(name="Deadpond-Dog")
|
|
||||||
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", pets=[pet_1])
|
|
||||||
hero_2 = Hero(
|
|
||||||
name="Spider-Boy",
|
|
||||||
secret_name="Pedro Parqueador",
|
|
||||||
pet=Pet(name="Spider-Boy-Dog"),
|
|
||||||
)
|
|
||||||
hero_3 = Hero(
|
|
||||||
name="Rusty-Man",
|
|
||||||
secret_name="Tommy Sharp",
|
|
||||||
age=48,
|
|
||||||
pet=Pet(name="Rusty-Man-Dog"),
|
|
||||||
)
|
|
||||||
|
|
||||||
SQLModel.metadata.create_all(config.engine)
|
|
||||||
|
|
||||||
with Session(config.engine) as session:
|
|
||||||
session.add(hero_1)
|
|
||||||
session.add(hero_2)
|
|
||||||
session.add(hero_3)
|
|
||||||
session.commit()
|
|
||||||
|
|
@ -3,7 +3,8 @@ from typing import TYPE_CHECKING
|
||||||
from pydantic import BaseSettings
|
from pydantic import BaseSettings
|
||||||
from sqlmodel import SQLModel, Session, create_engine
|
from sqlmodel import SQLModel, Session, create_engine
|
||||||
|
|
||||||
from learn_sql_model.models import Hero, Pet
|
from learn_sql_model.models.hero import Hero
|
||||||
|
from learn_sql_model.models.pet import Pet
|
||||||
from learn_sql_model.standard_config import load
|
from learn_sql_model.standard_config import load
|
||||||
|
|
||||||
models = [Hero, Pet]
|
models = [Hero, Pet]
|
||||||
|
|
@ -14,6 +15,7 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
database_url: str = "sqlite:///database.db"
|
database_url: str = "sqlite:///database.db"
|
||||||
|
port: int = 5000
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_prefix = "LEARN_SQL_MODEL_"
|
env_prefix = "LEARN_SQL_MODEL_"
|
||||||
|
|
@ -35,6 +37,11 @@ class Config(BaseSettings):
|
||||||
# app.get("/heroes/")(Hero.read_heroes)
|
# app.get("/heroes/")(Hero.read_heroes)
|
||||||
|
|
||||||
|
|
||||||
raw_config = load("learn_sql_model")
|
def get_config(overrides: dict = {}) -> Config:
|
||||||
config = Config(**raw_config)
|
raw_config = load("learn_sql_model")
|
||||||
config.create_db_and_tables()
|
config = Config(**raw_config, **overrides)
|
||||||
|
config.create_db_and_tables()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
|
||||||
0
learn_sql_model/factories/__init__.py
Normal file
0
learn_sql_model/factories/__init__.py
Normal file
7
learn_sql_model/factories/hero.py
Normal file
7
learn_sql_model/factories/hero.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from polyfactory.factories.pydantic_factory import ModelFactory
|
||||||
|
|
||||||
|
from learn_sql_model.models.hero import Hero
|
||||||
|
|
||||||
|
|
||||||
|
class HeroFactory(ModelFactory[Hero]):
|
||||||
|
__model__ = Hero
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel, select
|
|
||||||
|
|
||||||
|
|
||||||
class FastModel(SQLModel):
|
|
||||||
def post(self):
|
|
||||||
from learn_sql_model.config import config
|
|
||||||
|
|
||||||
with config.session as session:
|
|
||||||
session.add(self)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get(self, item_id: int = None):
|
|
||||||
from learn_sql_model.config import config
|
|
||||||
|
|
||||||
with config.session as session:
|
|
||||||
if item_id is None:
|
|
||||||
return session.exec(select(self)).all()
|
|
||||||
return session.exec(select(self).where(self.id == item_id)).one()
|
|
||||||
|
|
||||||
|
|
||||||
class Hero(FastModel, table=True):
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
name: str
|
|
||||||
secret_name: str
|
|
||||||
age: Optional[int] = None
|
|
||||||
# new_attribute: Optional[str] = None
|
|
||||||
# pets: List["Pet"] = Relationship(back_populates="hero")
|
|
||||||
|
|
||||||
|
|
||||||
class Pet(FastModel, table=True):
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
|
||||||
name: str = "Jim"
|
|
||||||
|
|
||||||
|
|
||||||
# age: Optional[int] = None
|
|
||||||
|
|
||||||
# hero_id: int = Field(default=None, foreign_key="hero.id")
|
|
||||||
# hero: Optional[Hero] = Relationship(back_populates="pets")
|
|
||||||
0
learn_sql_model/models/__init__.py
Normal file
0
learn_sql_model/models/__init__.py
Normal file
46
learn_sql_model/models/fast_model.py
Normal file
46
learn_sql_model/models/fast_model.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlmodel import SQLModel, select
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from learn_sql_model.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class FastModel(SQLModel):
|
||||||
|
def pre_post(self) -> None:
|
||||||
|
"""run before post"""
|
||||||
|
|
||||||
|
def pre_delete(self) -> None:
|
||||||
|
"""run before delete"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def pre_get(self) -> None:
|
||||||
|
"""run before get"""
|
||||||
|
|
||||||
|
def post(self, config: "Config" = None) -> None:
|
||||||
|
if config is None:
|
||||||
|
from learn_sql_model.config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
self.pre_post()
|
||||||
|
|
||||||
|
with config.session as session:
|
||||||
|
session.add(self)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(
|
||||||
|
self, item_id: int = None, config: "Config" = None
|
||||||
|
) -> Optional["FastModel"]:
|
||||||
|
if config is None:
|
||||||
|
from learn_sql_model.config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
self.pre_get()
|
||||||
|
|
||||||
|
with config.session as session:
|
||||||
|
if item_id is None:
|
||||||
|
return session.exec(select(self)).all()
|
||||||
|
return session.exec(select(self).where(self.id == item_id)).one()
|
||||||
13
learn_sql_model/models/hero.py
Normal file
13
learn_sql_model/models/hero.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field
|
||||||
|
|
||||||
|
from learn_sql_model.models.fast_model import FastModel
|
||||||
|
|
||||||
|
|
||||||
|
class Hero(FastModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
name: str
|
||||||
|
secret_name: str
|
||||||
|
age: Optional[int] = None
|
||||||
|
shoe_size: Optional[int] = None
|
||||||
16
learn_sql_model/models/pet.py
Normal file
16
learn_sql_model/models/pet.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlmodel import Field
|
||||||
|
|
||||||
|
from learn_sql_model.models.fast_model import FastModel
|
||||||
|
|
||||||
|
|
||||||
|
class Pet(FastModel, table=True):
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
name: str = "Jim"
|
||||||
|
|
||||||
|
|
||||||
|
# age: Optional[int] = None
|
||||||
|
|
||||||
|
# hero_id: int = Field(default=None, foreign_key="hero.id")
|
||||||
|
# hero: Optional[Hero] = Relationship(back_populates="pets")
|
||||||
|
|
@ -2,10 +2,11 @@ from logging.config import fileConfig
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from learn_sql_model.models import Hero, Pet
|
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from learn_sql_model.models.hero import Hero
|
||||||
|
from learn_sql_model.models.pet import Pet
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
|
||||||
29
migrations/versions/19d198151caf_add_shoe_size.py
Normal file
29
migrations/versions/19d198151caf_add_shoe_size.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""add shoe size
|
||||||
|
|
||||||
|
Revision ID: 19d198151caf
|
||||||
|
Revises: 20da26039edf
|
||||||
|
Create Date: 2023-05-19 13:41:45.070918
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '19d198151caf'
|
||||||
|
down_revision = '20da26039edf'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('hero', sa.Column('shoe_size', sa.Integer(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('hero', 'shoe_size')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -24,17 +24,18 @@ classifiers = [
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyconfig",
|
||||||
|
"fastapi",
|
||||||
|
"httpx",
|
||||||
|
"passlib[bcrypt]",
|
||||||
|
"polyfactory",
|
||||||
|
"python-jose[cryptography]",
|
||||||
|
"python-multipart",
|
||||||
"rich",
|
"rich",
|
||||||
|
"sqlmodel",
|
||||||
"textual",
|
"textual",
|
||||||
"typer",
|
"typer",
|
||||||
"anyconfig",
|
|
||||||
"sqlmodel",
|
|
||||||
"fastapi",
|
|
||||||
"uvicorn[standard]",
|
"uvicorn[standard]",
|
||||||
"httpx",
|
|
||||||
"python-jose[cryptography]",
|
|
||||||
"passlib[bcrypt]",
|
|
||||||
"python-multipart",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
|
|
@ -57,9 +58,9 @@ dependencies = [
|
||||||
"mypy",
|
"mypy",
|
||||||
"pyflyby",
|
"pyflyby",
|
||||||
"pytest",
|
"pytest",
|
||||||
|
'alembic',
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-mock",
|
"pytest-mock",
|
||||||
"pytest-rich",
|
|
||||||
"ruff",
|
"ruff",
|
||||||
"black",
|
"black",
|
||||||
]
|
]
|
||||||
|
|
@ -96,8 +97,7 @@ exclude_lines = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-ra -q --rich"
|
addopts = "-ra -q"
|
||||||
asyncio_mode = "auto"
|
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[tool.coverage_rich]
|
[tool.coverage_rich]
|
||||||
|
|
|
||||||
25
tests/test_hero.py
Normal file
25
tests/test_hero.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from learn_sql_model.config import Config, get_config
|
||||||
|
from learn_sql_model.factories.hero import HeroFactory
|
||||||
|
from learn_sql_model.models.hero import Hero
|
||||||
|
|
||||||
|
Hero
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config() -> Session:
|
||||||
|
tmp_db = tempfile.NamedTemporaryFile(suffix=".db")
|
||||||
|
config = get_config({"database_url": f"sqlite:///{tmp_db.name}"})
|
||||||
|
config.create_db_and_tables()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_hero(config: Config) -> None:
|
||||||
|
hero = HeroFactory().build(name="Batman", age=50)
|
||||||
|
hero.post(config=config)
|
||||||
|
assert hero.get(hero.id) == hero
|
||||||
|
breakpoint()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue