diff --git a/database/database.py b/database/database.py index 652cc14..6671d16 100644 --- a/database/database.py +++ b/database/database.py @@ -1,9 +1,13 @@ +from typing import List + from sqlalchemy import Column, Boolean, Float, String, Integer, ForeignKey, Table, func, ForeignKeyConstraint import sqlalchemy from sqlalchemy.orm import declarative_base, relationship, load_only +from database.models import Art, ArtnoID, Artist + # TODO read sensitive data from environment file or similar -SQLALCHEMY_DATABASE_URL = "postgresql://artnet_editor:G606Rm9sFEXe6wfTLxVu@127.0.0.1/artnet" +SQLALCHEMY_DATABASE_URL = "postgresql://artnet_editor:G606Rm9sFEXe6wfTLxVu@127.0.0.1/test_artnet" engine = sqlalchemy.create_engine(SQLALCHEMY_DATABASE_URL, echo=True) SessionLocal = sqlalchemy.orm.sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -12,8 +16,8 @@ Base = sqlalchemy.orm.declarative_base() art_tag_table = Table('art_tag', Base.metadata, - Column('art_id', Integer, ForeignKey('art.id')), - Column('tag_id', Integer, ForeignKey('tag.tag_id'))) + Column('art_id', Integer, ForeignKey('art.id')), + Column('tag_id', Integer, ForeignKey('tag.tag_id'))) artist_topic_table = Table('artist_topic', Base.metadata, Column('artist_id', Integer, ForeignKey('artist.id')), Column('topic_id', Integer, ForeignKey('topic.id'))) @@ -25,11 +29,11 @@ class DBPresence(Base): name = Column(String(20), primary_key=True) domain = Column(String(20), primary_key=True) link = Column(String, nullable=True) - artist_id = Column(Integer, ForeignKey('artist.id'), nullable=False, index=True) + artist_id = Column(Integer, ForeignKey('artist.id', ondelete="CASCADE"), nullable=False) - artist = relationship("DBArtist", backref='presences', foreign_keys=[artist_id]) - #artists = relationship('DBArtist', foreign_keys='DBArtist.id', back_populates="presences") - arts = relationship("DBArt", secondary="art_author", viewonly=True) + artist = relationship("DBArtist", back_populates="presences") + arts = relationship("DBArt", secondary="art_author", back_populates="presences", + cascade="delete, all") class DBArt(Base): @@ -41,8 +45,9 @@ class DBArt(Base): title = Column(String, nullable=True) link = Column(String, nullable=True) - tags = relationship("DBTag", secondary=art_tag_table, back_populates="art") - presences = relationship("DBPresence", secondary="art_author", viewonly=True) + tags = relationship("DBTag", secondary=art_tag_table, back_populates="art", viewonly=True) + presences = relationship("DBPresence", secondary="art_author", back_populates="arts", + cascade="all, delete") class DBArtPresence(Base): @@ -50,11 +55,11 @@ class DBArtPresence(Base): presence_name = Column(String(20), primary_key=True) presence_domain = Column(String(20), primary_key=True) - art_id = Column(Integer, ForeignKey(DBArt.id), primary_key=True) + art_id = Column(Integer, ForeignKey(DBArt.id, ondelete="CASCADE"), primary_key=True) __table_args__ = ( ForeignKeyConstraint( - ["presence_name", "presence_domain"], + ("presence_name", "presence_domain"), ["presence.name", "presence.domain"] ), ) @@ -64,9 +69,10 @@ class DBArtist(Base): __tablename__ = "artist" id = Column(Integer, primary_key=True) - description = Column(String, nullable=True) + description = Column(String, nullable=False) - topics = relationship("DBTopic", secondary="artist_topic", back_populates="artists") + topics = relationship("DBTopic", secondary="artist_topic", back_populates="artists", viewonly=True) + presences = relationship("DBPresence", back_populates="artist", cascade="all, delete") class DBTopic(Base): # as of now unused @@ -76,7 +82,7 @@ class DBTopic(Base): # as of now unused name = Column(String(20), unique=True, nullable=False) description = Column(String) - artists = relationship("DBArtist", secondary="artist_topic", back_populates="topics") + artists = relationship("DBArtist", secondary="artist_topic", back_populates="topics", viewonly=True) class DBTagCategory(Base): @@ -95,7 +101,9 @@ class DBTag(Base): category_id = Column(Integer, ForeignKey('tag_category.category_id')) category = relationship("DBTagCategory", backref='tags', foreign_keys=[category_id]) + # TODO check if cascade is required art = relationship("DBArt", secondary=art_tag_table, back_populates="tags") + # TODO check if cascade is required Base.metadata.create_all(bind=engine) @@ -103,31 +111,79 @@ Base.metadata.create_all(bind=engine) class Database: + __db: sqlalchemy.orm.Session = None + def __get_db(self) -> sqlalchemy.orm.Session: - db = SessionLocal() + Database.__db = SessionLocal() if Database.__db is None else Database.__db try: - return db + return Database.__db finally: - db.close() + ##Database.__db.close() + pass + + def __del__(self): + Database.__db.close() + Database.__db = None # Art + def get_art_list(self): + return self.__get_db().query(DBArt).all() # TODO FIX StackOverflow + def get_art_by_id(self, art_id: int): return self.__get_db().query(DBArt).where(DBArt.id == art_id).first() def get_art_by_hash(self, md5_hash: str): return self.__get_db().query(DBArt).filter(func.lower(DBArt.md5_hash) == md5_hash.lower()).first() - def update_art_by_id(self, art_id: int, title: str, authors: list, path: str, tags: list, link: str, - md5_hash: str): + def create_art_by_model(self, art: ArtnoID): + if not (isinstance(art.path, str) and len(art.path) > 0): + raise ValueError("New Art must contain a path!") + + db_art = DBArt(title=art.title, md5_hash=art.hash, path=art.path, link=art.link) + + if self.get_art_by_hash(art.hash) is not None: + raise ValueError("New Art must not contain already known hashes! Is this really new?") + + db = self.__get_db() + db.add(db_art) + db.commit() + + # TODO implement saving art <-> presence relationship as given by art.presences + if art.presences is not None: + for presence in art.presences: + db_art_presence = DBArtPresence(presence_name=presence.name, presence_domain=presence.domain, + art_id=db_art.id) + db.add(db_art_presence) + db.commit() + + return db_art.id + + @DeprecationWarning + def update_art_by_id(self, art_id: int, title: str, path: str, tags: list, link: str, + md5_hash: str, authors: list = None): raise NotImplementedError - def update_art_by_hash(self, md5_hash: str, title: str, authors: list, path: str, tags: list, link: str): + @DeprecationWarning + def update_art_by_hash(self, md5_hash: str, title: str, path: str, tags: list, link: str, authors: list = None): raise NotImplementedError + def update_art_by_model(self, art: Art): + db_art: DBArt = self.get_art_by_id(art.id) + + db_art.title = art.title if art.title is not None else db_art.title + db_art.link = art.link if art.link is not None else db_art.link + db_art.md5_hash = art.hash if art.hash is not None else db_art.md5_hash + db_art.path = art.path if art.path is not None else db_art.path + + self.__get_db().commit() + def delete_art_by_id(self, art_id: int): - raise NotImplementedError + db_art: DBArt = self.get_art_by_id(art_id) + self.__get_db().delete(db_art) + self.__get_db().commit() + @DeprecationWarning def delete_art_by_hash(self, md5_hash: str): raise NotImplementedError @@ -156,17 +212,40 @@ class Database: # Presence + def get_presence_list(self): + return self.__get_db().query(DBPresence).all() # TODO fix StackOverflow + def get_presence(self, name: str, domain: str) -> DBPresence: result = self.__get_db().query(DBPresence)\ .filter(func.lower(DBPresence.name) == name.lower() and func.lower(DBPresence.domain) == domain.lower()).first() return result + def create_presence(self, name: str, domain: str, artist_id: int, link: str): + if not (len(name) > 0 and len(domain) > 0): + print(f"Name: \"{name}\" Domain: \"{domain}\"") + raise ValueError("New Presence must have some name and domain!") + + db_presence = DBPresence(name=name, domain=domain, artist_id=artist_id, link=link) + + db = self.__get_db() + db.add(db_presence) + db.commit() + + return db_presence.name, db_presence.domain + def update_presence(self, name: str, domain: str, artist_id: int, link: str): - raise NotImplementedError + db_presence = self.get_presence(name=name, domain=domain) + + db_presence.link = link + db_presence.artist_id = artist_id + + self.__get_db().commit() def delete_presence(self, name: str, domain: str): - raise NotImplementedError + db_presence = self.get_presence(name=name, domain=domain) + self.__get_db().delete(db_presence) + self.__get_db().commit() # Artist -> Presence @@ -176,15 +255,43 @@ class Database: # Artist - def get_artist(self, artist_id: int): + def get_artist_list(self): + return self.__get_db().query(DBArtist).all() # TODO fix StackOverflow + + def get_artist(self, artist_id: int) -> DBArtist: result = self.__get_db().query(DBArtist).where(DBArtist.id == artist_id).first() return result - def update_artist(self, artist_id: int, name: str): - raise NotImplementedError + def create_artist(self, name: str, topics: List[int]): + db_artist = DBArtist(description=name, topics=topics) + + db = self.__get_db() + db.add(db_artist) + db.commit() + + return db_artist + + def search_artist(self, name: str) -> Artist: + result = self.__get_db().query(DBArtist).where(DBArtist.description == name).all() + return result + + def update_artist(self, artist_id: int, name: str = None, topics: List[int] = None): + db_artist = self.get_artist(artist_id) + + if name is not None: + db_artist.description = name + if topics is not None: + db_artist.topics = topics + + self.__get_db().commit() def delete_artist(self, artist_id: int): - raise NotImplementedError + db_artist = self.get_artist(artist_id) + + self.__get_db().delete(db_artist) + self.__get_db().commit() # TODO FIX sqlalchemy.exc.IntegrityError: (psycopg2.errors.ForeignKeyViolation) + # TODO update or delete on table "artist" violates foreign key constraint "presence_artist_id_fkey" on table "presence" + # TODO DETAIL: Key (id)=(83) is still referenced from table "presence". # Topic diff --git a/database/models.py b/database/models.py index ff07135..af45df3 100644 --- a/database/models.py +++ b/database/models.py @@ -2,12 +2,57 @@ from pydantic import BaseModel from typing import List, Optional -class Art(BaseModel): - id: int - hash: str - path: str - title: Optional[str] = None - link: Optional[str] = None +class TopicNoId(BaseModel): + name: str + description: Optional[str] class Config: orm_mode = True + + +class Topic(TopicNoId): + topic_id: int + + +class ArtistNoId(BaseModel): + description: str + topics: Optional[List[int]] + + class Config: + orm_mode = True + + +class Artist(ArtistNoId): + artist_id: int + + class Config: + orm_mode = True + + +class Presence(BaseModel): + name: str + domain: str + link: Optional[str] + artist_id: Optional[int] + + class Config: + orm_mode = True + + +class ArtnoID(BaseModel): + hash: Optional[str] + path: Optional[str] + title: Optional[str] = None + link: Optional[str] = None + presences: Optional[List[Presence]] + + class Config: + orm_mode = True + + +class Art(ArtnoID): + id: int + + class Config: + orm_mode = True + diff --git a/main.py b/main.py index 5dc3ce2..0e67d84 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,9 @@ from fastapi import FastAPI, HTTPException import uvicorn +from typing import List, Tuple from database.database import Database +from database.models import Art, ArtnoID, Presence, ArtistNoId app = FastAPI() db = Database() @@ -9,8 +11,9 @@ db = Database() @app.get("/artnet/metadata/art") async def art(id: int = None, hash: str = None, tag_id: int = None): - if id is None and hash is None and tag_id is None: - raise HTTPException(status_code=406, detail="requires id or hash param") + print(f"Received GET on /artnet/metadata/art (id={id}, hash={hash}, tag_id={tag_id})") + #if id is None and hash is None and tag_id is None: + #raise HTTPException(status_code=406, detail="requires id or hash param") result = None if id is not None: @@ -20,17 +23,63 @@ async def art(id: int = None, hash: str = None, tag_id: int = None): elif tag_id is not None: result = db.get_tag_art(tag_id) + if id is None and hash is None and tag_id is None: # TODO fix against listing enormous amounts of art (because all could be large) + result = db.get_art_list() + if result is not None: return result raise HTTPException(status_code=404, detail="Art was not found!") +@app.post("/artnet/metadata/art") +async def art(art: ArtnoID, id: int = None): + print(f"Received POST on /artnet/metadata/art (id={id}) body: art=({art})") + if id is None: # create new art entry + if art.presences is None: # tried to create art without any presence + raise HTTPException(status_code=422, detail="No presences were listed") + try: + for presence in art.presences: + r = db.get_presence(name=presence.name, domain=presence.domain) + #if db.get_presence(name=presence.name, domain=presence.domain) is None: + # raise HTTPException(status_code=422, + # detail=f"Presence ({presence.name}, {presence.domain}) is unknown!") + #db.create # create presence-art relationship + # TODO validate & create presence-art relationship + + new_id = db.create_art_by_model(art) + return {"id": new_id} + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + else: # update existing art entry + if db.get_art_by_id(id) is None: + return HTTPException(status_code=404, detail="The specified art could not be found!") + + # TODO implement linking to one or multiple presences + # TODO enforcing at least one presence + updated_art = Art(id=id) + updated_art.hash = art.hash + updated_art.link = art.link + updated_art.title = art.title + updated_art.path = art.path + + db.update_art_by_model(updated_art) + + return True + + +@app.delete("/artnet/metadata/art") +async def art(id: int): + print(f"Received DELETE on /artnet/metadata/art (id={id})") + if db.get_art_by_id(id) is None: + raise HTTPException(status_code=404, detail="Art has not been found!") + db.delete_art_by_id(id) + + @app.get("/artnet/metadata/presence") async def presence(name: str = None, domain: str = None, artist_id: int = None, art_id: int = None, art_md5: str = None): - if name is None and domain is None and artist_id is None and art_id is None and art_md5 is None: - raise HTTPException(status_code=406, detail="You must query with at least one parameter!") - + print(f"Received GET on /artnet/metadata/presence (name={name}, domain={domain}, artist_id={artist_id}" + f", art_id={art_id}, art_md5={art_md5})") result = None if artist_id is not None and name is None and domain is None: result = db.get_artist_presences(artist_id) @@ -44,28 +93,84 @@ async def presence(name: str = None, domain: str = None, artist_id: int = None, elif name is not None and domain is not None: result = db.get_presence(name, domain) + if name is None and domain is None and artist_id is None and art_id is None and art_md5 is None: + result = db.get_presence_list() + #raise HTTPException(status_code=406, detail="You must query with at least one parameter!") + if result is not None: return result raise HTTPException(status_code=404, detail="Presence was not found!") -@app.get("/artnet/metadata/artist") -async def artist(id: int = None, topic_id: int = None): - if id is None and topic_id is None: - raise HTTPException(status_code=406) +@app.post("/artnet/metadata/presence") +async def presence(presence: Presence): + print(f"Received POST on /artnet/metadata/presence body: presence={presence}") + curr_presence = db.get_presence(name=presence.name, domain=presence.domain) + r = db.get_artist(presence.artist_id) + if r is None: + raise HTTPException(status_code=406, detail="Invalid Artist ID! Could not find artist.") + + if curr_presence is None: # must be new presence + db.create_presence(name=presence.name, domain=presence.domain, artist_id=presence.artist_id, link=presence.link) + else: # editing current presence + db.update_presence(name=curr_presence.name, domain=curr_presence.domain, + artist_id=presence.artist_id, link=presence.link) + + +@app.delete("/artnet/metadata/presence") +async def presence(name: str, domain: str): + print(f"Received DELETE on /artnet/metadata/presence (name={name}, domain={domain})") + if db.get_presence(name, domain) is None: + raise HTTPException(status_code=404, detail="Presence has not been found!") + db.delete_presence(name, domain) + + +@app.get("/artnet/metadata/artist") +async def artist(id: int = None, topic_id: int = None, description: str = None): + print(f"Received GET on /artnet/metadata/artist (id={id}, topic_id={topic_id}, description={description})") result = None + if id is None and topic_id is None and description is None: + result = db.get_artist_list() + if id is not None: result = db.get_artist(id) elif topic_id is not None: result = db.get_topic_artists(topic_id) + elif description is not None: + result = db.search_artist(description) + if result is not None: return result raise HTTPException(status_code=404, detail="Artist was not found!") +@app.post("/artnet/metadata/artist") +async def artist(artist: ArtistNoId, id: int = None): + print(f"Received POST on /artnet/metadata/artist (id={id}) body: artist={artist}") + if id is None: # create new artist + db_artist = db.create_artist(name=artist.description, topics=artist.topics) + return db_artist.id + + else: + if db.get_artist(id) is not None: + db.update_artist(artist_id=id, name=artist.description, topics=artist.topics) + else: + raise HTTPException(status_code=404, detail="Tried to edit unknown artist. ID was not found!") + + +@app.delete("/artnet/metadata/artist") +async def artist(id: int): + print(f"Received DELETE on /artnet/metadata/artist (id={id})") + if db.get_artist(id) is None: + raise HTTPException(status_code=404, detail="Tried to delete unknown artist. ID was not found!") + db.delete_artist(id) + + + @app.get("/artnet/metadata/topic") async def topic(name: str = None, id: int = None, artist_id: int = None): + print(f"Received GET on /artnet/metadata/topic (name={name}, id={id}, artist_id={artist_id})") if name is None and id is None and artist_id is None: raise HTTPException(status_code=406)