#19 Implement link deletion

Merged
getty merged 8 commits from getty/gr/add-deletion into getty/master 4 years ago
11 changed files with 125 additions and 11 deletions
  1. 8 1
      lc/app.py
  2. 11 0
      lc/error.py
  3. 4 1
      lc/model.py
  4. 2 0
      lc/static/jquery-3.5.0.min.js
  5. 27 0
      lc/static/lc.js
  6. 10 0
      lc/static/main.css
  7. 12 8
      lc/web.py
  8. 6 0
      stubs/peewee.py
  9. 4 1
      templates/link.mustache
  10. 2 0
      templates/main.mustache
  11. 39 0
      tests/routes.py

+ 8 - 1
lc/app.py

@@ -142,7 +142,9 @@ class CreateLink(Endpoint):
 @endpoint("/u/<string:user>/l/<string:link>")
 @endpoint("/u/<string:user>/l/<string:link>")
 class GetLink(Endpoint):
 class GetLink(Endpoint):
     def api_get(self, user: str, link: str):
     def api_get(self, user: str, link: str):
-        pass
+        u = self.require_authentication(user)
+        l = u.get_link(int(link))
+        return self.api_ok(l.link_url(), l.to_dict())
 
 
     def api_post(self, user: str, link: str):
     def api_post(self, user: str, link: str):
         u = self.require_authentication(user)
         u = self.require_authentication(user)
@@ -151,6 +153,11 @@ class GetLink(Endpoint):
         l.update_from_request(u, req)
         l.update_from_request(u, req)
         raise e.LCRedirect(l.link_url())
         raise e.LCRedirect(l.link_url())
 
 
+    def api_delete(self, user: str, link: str):
+        u = self.require_authentication(user)
+        u.get_link(int(link)).delete_instance()
+        return self.api_ok(u.base_url())
+
     def html(self, user: str, link: str):
     def html(self, user: str, link: str):
         l = m.User.by_slug(user).get_link(int(link))
         l = m.User.by_slug(user).get_link(int(link))
         return render(
         return render(

+ 11 - 0
lc/error.py

@@ -47,6 +47,17 @@ class NoSuchUser(LCException):
         return 404
         return 404
 
 
 
 
+@dataclass
+class NoSuchLink(LCException):
+    link_id: int
+
+    def __str__(self):
+        return f"No link {self.link_id} exists."
+
+    def http_code(self) -> int:
+        return 404
+
+
 @dataclass
 @dataclass
 class BadPassword(LCException):
 class BadPassword(LCException):
     name: str
     name: str

+ 4 - 1
lc/model.py

@@ -85,7 +85,10 @@ class User(Model):
         return link_views, pagination
         return link_views, pagination
 
 
     def get_link(self, link_id: int) -> "Link":
     def get_link(self, link_id: int) -> "Link":
-        return Link.get((Link.user == self) & (Link.id == link_id))
+        try:
+            return Link.get((Link.user == self) & (Link.id == link_id))
+        except Link.DoesNotExist:
+            raise e.NoSuchLink(link_id)
 
 
     def get_tag(self, tag_name: str) -> "Tag":
     def get_tag(self, tag_name: str) -> "Tag":
         return Tag.get((Tag.user == self) & (Tag.name == tag_name))
         return Tag.get((Tag.user == self) & (Tag.name == tag_name))

File diff suppressed because it is too large
+ 2 - 0
lc/static/jquery-3.5.0.min.js


+ 27 - 0
lc/static/lc.js

@@ -0,0 +1,27 @@
+function confirmDelete(url, id) {
+    if ($(`#confirm_${id}`).length > 0) {
+        return;
+    }
+
+    let link = $(`#delete_${id}`);
+    let confirm = link.append(
+        `<span class="deleteconfirm" id="confirm_${id}">Are you sure?
+           <a id="do_delete_${id}">yes</a>
+           <a id="cancel_delete_${id}">no</a>
+         </span>`);
+    $(document).on('click', `a#do_delete_${id}`, function() {
+        var req = new XMLHttpRequest();
+        req.addEventListener("load", function() {
+            $(`#link_${id}`).remove();
+        });
+        req.open("DELETE", url);
+        req.send();
+    });
+    $(document).on('click', `a#cancel_delete_${id}`, function() {
+        $(`#confirm_${id}`).remove();
+    });
+}
+
+function removeConfirm(id) {
+    $(`#confirm_${id}`).remove();
+}

+ 10 - 0
lc/static/main.css

@@ -175,3 +175,13 @@ form > div {
     padding: 0.2em 2em;
     padding: 0.2em 2em;
     margin: 0em 1em;
     margin: 0em 1em;
 }
 }
+
+
+.deleteconfirm {
+    padding-left: 1em;
+    font-size: small;
+}
+
+.deleteconfirm a {
+    color: #f00;
+}

+ 12 - 8
lc/web.py

@@ -46,12 +46,10 @@ class Endpoint:
                 self.user = u
                 self.user = u
 
 
     def api_ok(self, redirect: str, data: dict = {"status": "ok"}) -> ApiOK:
     def api_ok(self, redirect: str, data: dict = {"status": "ok"}) -> ApiOK:
-        if flask.request.content_type == "application/json":
-            return ApiOK(response=data)
-        elif flask.request.content_type == "application/x-www-form-urlencoded":
+        if flask.request.content_type == "application/x-www-form-urlencoded":
             raise e.LCRedirect(redirect)
             raise e.LCRedirect(redirect)
         else:
         else:
-            raise e.BadContentType(flask.request.content_type or "unknown")
+            return ApiOK(response=data)
 
 
     def request_data(self, cls: Type[T]) -> T:
     def request_data(self, cls: Type[T]) -> T:
         """Construct a Request model from either a JSON payload or a urlencoded payload"""
         """Construct a Request model from either a JSON payload or a urlencoded payload"""
@@ -89,6 +87,8 @@ class Endpoint:
                 api_ok = self.api_post(*args, **kwargs)
                 api_ok = self.api_post(*args, **kwargs)
                 assert isinstance(api_ok, ApiOK)
                 assert isinstance(api_ok, ApiOK)
                 return flask.jsonify(api_ok.response)
                 return flask.jsonify(api_ok.response)
+            elif flask.request.method == "DELETE":
+                return flask.jsonify(self.api_delete(*args, **kwargs).response)
             elif (
             elif (
                 flask.request.method in ["GET", "HEAD"]
                 flask.request.method in ["GET", "HEAD"]
                 and flask.request.content_type == "application/json"
                 and flask.request.content_type == "application/json"
@@ -99,7 +99,7 @@ class Endpoint:
                 # I like using the HTTP headers to distinguish these
                 # I like using the HTTP headers to distinguish these
                 # cases, while other APIs tend to have a separate /api
                 # cases, while other APIs tend to have a separate /api
                 # endpoint to do this.
                 # endpoint to do this.
-                return flask.jsonify(self.api_get(*args, **kwargs))
+                return flask.jsonify(self.api_get(*args, **kwargs).response)
         # if an exception arose from an "API method", then we should
         # if an exception arose from an "API method", then we should
         # report it as JSON
         # report it as JSON
         except e.LCException as exn:
         except e.LCException as exn:
@@ -122,9 +122,11 @@ class Endpoint:
         try:
         try:
             return self.html(*args, **kwargs)
             return self.html(*args, **kwargs)
         except e.LCException as exn:
         except e.LCException as exn:
-            page = render(
-                "main", title="error", content=f"shit's fucked yo: {exn}", user=None,
-            )
+            page = render("main", v.Page(
+                title="error",
+                content=f"shit's fucked yo: {exn}",
+                user=self.user,
+            ))
             return (page, exn.http_code())
             return (page, exn.http_code())
         except e.LCRedirect as exn:
         except e.LCRedirect as exn:
             return flask.redirect(exn.to_path())
             return flask.redirect(exn.to_path())
@@ -154,6 +156,8 @@ def endpoint(route: str):
         methods = ["GET"]
         methods = ["GET"]
         if "api_post" in dir(endpoint_class):
         if "api_post" in dir(endpoint_class):
             methods.append("POST")
             methods.append("POST")
+        if "api_delete" in dir(endpoint_class):
+            methods.append("DELETE")
 
 
         # this is just for making error messages nicer
         # this is just for making error messages nicer
         func.__name__ = endpoint_class.__name__
         func.__name__ = endpoint_class.__name__

+ 6 - 0
stubs/peewee.py

@@ -11,6 +11,9 @@ T = TypeVar("T", bound="Model")
 class Model:
 class Model:
     id: int
     id: int
 
 
+    class DoesNotExist(Exception):
+        pass
+
     @classmethod
     @classmethod
     def create(cls: Type[T], **kwargs) -> T:
     def create(cls: Type[T], **kwargs) -> T:
         pass
         pass
@@ -30,6 +33,9 @@ class Model:
     def save(self):
     def save(self):
         pass
         pass
 
 
+    def delete_instance(self) -> Any:
+        pass
+
 
 
 # These all do things that MyPy chokes on, so we're going to treat
 # These all do things that MyPy chokes on, so we're going to treat
 # them like methods instead of naming classes
 # them like methods instead of naming classes

+ 4 - 1
templates/link.mustache

@@ -1,4 +1,4 @@
-<div class="link{{#private}} private{{/private}}">
+<div id="link_{{id}}" class="link{{#private}} private{{/private}}">
   <div class="text"><a href="{{url}}">{{name}}</a></div>
   <div class="text"><a href="{{url}}">{{name}}</a></div>
   <div class="url"><a href="{{url}}">{{url}}</a></div>
   <div class="url"><a href="{{url}}">{{url}}</a></div>
   <div class="taglist">{{#tags}}
   <div class="taglist">{{#tags}}
@@ -8,6 +8,9 @@
     <a href="{{link_url}}">{{created}}</a>
     <a href="{{link_url}}">{{created}}</a>
     {{#is_mine}}
     {{#is_mine}}
       <a href="{{link_url}}/edit">edit</a>
       <a href="{{link_url}}/edit">edit</a>
+      <span id="delete_{{id}}">
+        <a class="deletelink" onClick="confirmDelete('{{link_url}}', {{id}})" >delete</a>
+      </span>
     {{/is_mine}}
     {{/is_mine}}
   </div>
   </div>
 </div>
 </div>

+ 2 - 0
templates/main.mustache

@@ -5,6 +5,8 @@
     <link rel="icon" type="image/png" href="/static/lc_64.png" />
     <link rel="icon" type="image/png" href="/static/lc_64.png" />
     <title>Lament Configuration</title>
     <title>Lament Configuration</title>
     <link rel="stylesheet" type="text/css" href="/static/main.css" />
     <link rel="stylesheet" type="text/css" href="/static/main.css" />
+    <script type="text/javascript" src="/static/jquery-3.5.0.min.js"></script>
+    <script type="text/javascript" src="/static/lc.js"></script>
   </head>
   </head>
   <body>
   <body>
     <div class="header">
     <div class="header">

+ 39 - 0
tests/routes.py

@@ -105,3 +105,42 @@ class TestRoutes:
             headers={"Authorization": f"Bearer {token}"},
             headers={"Authorization": f"Bearer {token}"},
         )
         )
         assert result.status == "403 FORBIDDEN"
         assert result.status == "403 FORBIDDEN"
+
+    def test_successful_api_delete_link(self):
+        password = "foo"
+        u = self.mk_user(password=password)
+        result = self.app.post("/auth", json={"name": u.name, "password": password})
+        assert result.status == "200 OK"
+        token = result.json["token"]
+
+        sample_url = "http://example.com/"
+        result = self.app.post(
+            f"/u/{u.name}/l",
+            json={
+                "url": sample_url,
+                "name": "Example Dot Com",
+                "description": "Some Description",
+                "private": False,
+                "tags": ["website"],
+            },
+        )
+        link_id = result.json["id"]
+
+        # this should be fine
+        check_link = self.app.get(
+            f"/u/{u.name}/l/{link_id}", headers={"Content-Type": "application/json"},
+        )
+        assert check_link.status == "200 OK"
+        assert check_link.json["url"] == sample_url
+
+        # delete the link
+        delete_link = self.app.delete(
+            f"/u/{u.name}/l/{link_id}", headers={"Authorization": f"Bearer {token}"},
+        )
+        assert delete_link.status == "200 OK"
+
+        # make sure it is gone
+        bad_result = self.app.get(
+            f"/u/{u.name}/l/{link_id}", headers={"Content-Type": "application/json"},
+        )
+        assert bad_result.status == "404 NOT FOUND"